From fbb7c6c5eebe1d54db600fa63e820a29ccb2a9dc Mon Sep 17 00:00:00 2001 From: mkovalua Date: Thu, 30 Oct 2025 10:40:34 +0200 Subject: [PATCH 01/14] form chart row title using Elasticsearch response by extracting relevant title using guid (title may be changed after saving in Elastic) --- .../features/analytics/analytics.component.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index c1d008b57..8cc2f1817 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -35,6 +35,9 @@ import { AnalyticsKpiComponent } from './components'; import { DATE_RANGE_OPTIONS } from './constants'; import { DateRange } from './enums'; import { AnalyticsSelectors, ClearAnalytics, GetMetrics, GetRelatedCounts } from './store'; +import { GetResource, GetResourceDetails } from '@shared/stores/current-resource'; +import { ResourceType } from '@shared/enums'; +import { firstValueFrom } from 'rxjs'; @Component({ selector: 'osf-analytics', @@ -84,6 +87,8 @@ export class AnalyticsComponent implements OnInit { getMetrics: GetMetrics, getRelatedCounts: GetRelatedCounts, clearAnalytics: ClearAnalytics, + getResourceDetails: GetResourceDetails, + getGetResource: GetResource, }); visitsLabels: string[] = []; @@ -140,7 +145,9 @@ export class AnalyticsComponent implements OnInit { navigateToLinkedProjects() { this.router.navigate(['linked-projects'], { relativeTo: this.route }); } + private setData() { + alert('setData'); const analytics = this.analytics(); if (!analytics) { @@ -171,7 +178,31 @@ export class AnalyticsComponent implements OnInit { }, ]; - this.popularPagesLabels = analytics.popularPages.map((item) => item.title); + // use to not do additional requests if title already received + let guid_title_mapping: Record = {}; + + this.popularPagesLabels = analytics.popularPages.map((item) => { + const parts = item.path.split('/').filter(Boolean); + const guid = parts[0] || null; + const resource = parts[1] || 'overview'; + + if (guid && item.route.includes('project.detail')) { + this.actions.getResourceDetails(guid, ResourceType.Project); + } + if (guid && item.route.includes('registry.detail')) { + // get title + this.actions.getGetResource(guid).subscribe((details) => { + console.log(details); + // alert(details.title) + }); + } + if (guid && item.route.includes('preprint.detail')) { + this.actions.getResourceDetails(guid, ResourceType.Preprint); + } + return `${guid} ${resource}`; + }); + + alert(JSON.stringify(this.popularPagesLabels)); this.popularPagesDataset = [ { label: this.translateService.instant('project.analytics.charts.popularPages'), From 3535e3be05d00504934db7b8b7ec9e7034174fc6 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Thu, 30 Oct 2025 10:50:13 +0200 Subject: [PATCH 02/14] fix linter errors --- src/app/features/analytics/analytics.component.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index 8cc2f1817..6029a6b18 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -37,7 +37,6 @@ import { DateRange } from './enums'; import { AnalyticsSelectors, ClearAnalytics, GetMetrics, GetRelatedCounts } from './store'; import { GetResource, GetResourceDetails } from '@shared/stores/current-resource'; import { ResourceType } from '@shared/enums'; -import { firstValueFrom } from 'rxjs'; @Component({ selector: 'osf-analytics', @@ -179,7 +178,7 @@ export class AnalyticsComponent implements OnInit { ]; // use to not do additional requests if title already received - let guid_title_mapping: Record = {}; + // let guid_title_mapping: Record = {}; this.popularPagesLabels = analytics.popularPages.map((item) => { const parts = item.path.split('/').filter(Boolean); From 3fb6fe7fe9875935891aaec19e4dafd1bc54ec75 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 31 Oct 2025 16:25:03 +0200 Subject: [PATCH 03/14] format popularPagesLabels to show the resource of view --- .../features/analytics/analytics.component.ts | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index 6029a6b18..182b6f83c 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -30,13 +30,12 @@ import { } from '@osf/shared/components'; import { hasViewOnlyParam, IS_WEB, Primitive } from '@osf/shared/helpers'; import { DatasetInput } from '@osf/shared/models'; +import { CurrentResourceSelectors, GetResource, GetResourceDetails } from '@shared/stores/current-resource'; import { AnalyticsKpiComponent } from './components'; import { DATE_RANGE_OPTIONS } from './constants'; import { DateRange } from './enums'; import { AnalyticsSelectors, ClearAnalytics, GetMetrics, GetRelatedCounts } from './store'; -import { GetResource, GetResourceDetails } from '@shared/stores/current-resource'; -import { ResourceType } from '@shared/enums'; @Component({ selector: 'osf-analytics', @@ -81,6 +80,7 @@ export class AnalyticsComponent implements OnInit { isRelatedCountsLoading = select(AnalyticsSelectors.isRelatedCountsLoading); isMetricsError = select(AnalyticsSelectors.isMetricsError); + resourceDetails = select(CurrentResourceSelectors.getResourceDetails); actions = createDispatchMap({ getMetrics: GetMetrics, @@ -146,7 +146,6 @@ export class AnalyticsComponent implements OnInit { } private setData() { - alert('setData'); const analytics = this.analytics(); if (!analytics) { @@ -177,28 +176,13 @@ export class AnalyticsComponent implements OnInit { }, ]; - // use to not do additional requests if title already received - // let guid_title_mapping: Record = {}; - this.popularPagesLabels = analytics.popularPages.map((item) => { const parts = item.path.split('/').filter(Boolean); - const guid = parts[0] || null; - const resource = parts[1] || 'overview'; - - if (guid && item.route.includes('project.detail')) { - this.actions.getResourceDetails(guid, ResourceType.Project); - } - if (guid && item.route.includes('registry.detail')) { - // get title - this.actions.getGetResource(guid).subscribe((details) => { - console.log(details); - // alert(details.title) - }); - } - if (guid && item.route.includes('preprint.detail')) { - this.actions.getResourceDetails(guid, ResourceType.Preprint); - } - return `${guid} ${resource}`; + const resource = parts[1].replace('-', ' ') || 'overview'; + // remove redundant 'OSF |' for title beginning + const cleanTitle = item.title === 'OSF' ? item.title : item.title.replace(/^OSF \| /, ''); + // add resource suffix to title to keep it more explicit what detail page is a resource of metrics call + return cleanTitle.endsWith(resource) ? cleanTitle : `${cleanTitle} | ${resource}`; }); alert(JSON.stringify(this.popularPagesLabels)); From 520e966aedf0a4326d83f6091d1b3dfad14a5c7f Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 31 Oct 2025 16:41:34 +0200 Subject: [PATCH 04/14] count usage for registration and project detail tabs --- src/app/app.component.ts | 50 ++++++++++++++++++- .../features/analytics/analytics.component.ts | 1 - .../preprint-details.component.ts | 7 --- .../overview/project-overview.component.ts | 6 --- .../features/registry/registry.component.ts | 7 --- .../shared/models/current-resource.model.ts | 1 + .../models/guid-response-json-api.model.ts | 1 + src/app/shared/services/analytics.service.ts | 20 ++++++-- src/app/shared/services/resource.service.ts | 1 + 9 files changed, 67 insertions(+), 27 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index e1eb1045b..f0194688e 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -2,7 +2,7 @@ import { Actions, createDispatchMap, ofActionSuccessful, select } from '@ngxs/st import { filter, take } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; @@ -10,7 +10,9 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { GetCurrentUser } from '@core/store/user'; import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails'; import { ConfirmEmailComponent } from '@shared/components'; -import { CustomDialogService } from '@shared/services'; +import { CurrentResourceType } from '@shared/enums'; +import { AnalyticsService, CustomDialogService } from '@shared/services'; +import { CurrentResourceSelectors } from '@shared/stores/current-resource'; import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; @@ -30,9 +32,16 @@ export class AppComponent implements OnInit { private readonly router = inject(Router); private readonly environment = inject(ENVIRONMENT); private readonly actions$ = inject(Actions); + private readonly analyticsService = inject(AnalyticsService); private readonly actions = createDispatchMap({ getCurrentUser: GetCurrentUser, getEmails: GetEmails }); unverifiedEmails = select(UserEmailsSelectors.getUnverifiedEmails); + currentResource = select(CurrentResourceSelectors.getCurrentResource); + isProjectOrRegistration = computed( + () => + this.currentResource()?.type === CurrentResourceType.Projects || + this.currentResource()?.type === CurrentResourceType.Registrations + ); constructor() { effect(() => { @@ -62,6 +71,43 @@ export class AppComponent implements OnInit { }); }); } + + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((event: NavigationEnd) => { + this.sendCountedUsageForRegistrationAndProjects(event.urlAfterRedirects); + }); + } + + sendCountedUsageForRegistrationAndProjects(urlPath: string) { + const detailsPaths = [ + '/overview', + '/metadata/osf', + '/files', + '/resources', + '/wiki', + '/components', + '/contributors', + '/links', + '/analytics', + '/recent-activity', + ]; + // check if it is detail page tab is opened for Project or Registration to log into metrics + if (this.isProjectOrRegistration() && detailsPaths.some((path) => urlPath.endsWith(path))) { + const resource = this.currentResource(); + let route = urlPath.split('/').filter(Boolean).join('.'); + + if (resource?.type) { + route = `${resource?.type}.${route}`; + } + + if (resource) { + this.analyticsService.sendCountedUsage(resource, route).subscribe(); + } + } } private showEmailDialog() { diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index 182b6f83c..de90c1a1c 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -185,7 +185,6 @@ export class AnalyticsComponent implements OnInit { return cleanTitle.endsWith(resource) ? cleanTitle : `${cleanTitle} | ${resource}`; }); - alert(JSON.stringify(this.popularPagesLabels)); this.popularPagesDataset = [ { label: this.translateService.instant('project.analytics.charts.popularPages'), diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index ff01240b4..1e7a76ddd 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -14,7 +14,6 @@ import { Component, computed, DestroyRef, - effect, HostBinding, inject, OnDestroy, @@ -160,12 +159,6 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { constructor() { this.helpScoutService.setResourceType('preprint'); - effect(() => { - const currentPreprint = this.preprint(); - if (currentPreprint && currentPreprint.isPublic) { - this.analyticsService.sendCountedUsage(currentPreprint.id, 'preprint.detail').subscribe(); - } - }); } private currentUserIsAdmin = computed(() => { diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 291cb4254..d58dfb5b4 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -374,12 +374,6 @@ export class ProjectOverviewComponent implements OnInit { this.actions.getHomeWiki(ResourceType.Project, project.id); } }); - effect(() => { - const currentProject = this.currentProject(); - if (currentProject && currentProject.isPublic) { - this.analyticsService.sendCountedUsage(currentProject.id, 'project.detail').subscribe(); - } - }); } private setupRouteChangeListener(): void { diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index 03ff2b96f..e0b141ea7 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -58,13 +58,6 @@ export class RegistryComponent implements OnDestroy { } }); - effect(() => { - const currentRegistry = this.registry(); - if (currentRegistry && currentRegistry.isPublic) { - this.analyticsService.sendCountedUsage(currentRegistry.id, 'registry.detail').subscribe(); - } - }); - this.dataciteService.logIdentifiableView(this.registry$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); } diff --git a/src/app/shared/models/current-resource.model.ts b/src/app/shared/models/current-resource.model.ts index db1ab3faf..158ea6d0b 100644 --- a/src/app/shared/models/current-resource.model.ts +++ b/src/app/shared/models/current-resource.model.ts @@ -8,4 +8,5 @@ export interface CurrentResource { rootResourceId?: string; wikiEnabled?: boolean; permissions: UserPermissions[]; + title?: string; } diff --git a/src/app/shared/models/guid-response-json-api.model.ts b/src/app/shared/models/guid-response-json-api.model.ts index 375488956..ffea7cf9c 100644 --- a/src/app/shared/models/guid-response-json-api.model.ts +++ b/src/app/shared/models/guid-response-json-api.model.ts @@ -11,6 +11,7 @@ interface GuidDataJsonApi { guid: string; wiki_enabled: boolean; current_user_permissions: UserPermissions[]; + title?: string; }; relationships: { target?: { diff --git a/src/app/shared/services/analytics.service.ts b/src/app/shared/services/analytics.service.ts index b7500870e..be7cbe405 100644 --- a/src/app/shared/services/analytics.service.ts +++ b/src/app/shared/services/analytics.service.ts @@ -3,6 +3,7 @@ import { Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { CurrentResource } from '@osf/shared/models'; import { JsonApiService } from '@osf/shared/services'; @Injectable({ providedIn: 'root' }) @@ -14,23 +15,34 @@ export class AnalyticsService { return `${this.environment.apiDomainUrl}/_/metrics/events/counted_usage/`; } - sendCountedUsage(guid: string, routeName: string): Observable { - const payload = { + getPageviewPayload(resource: CurrentResource, routeName: string) { + // TODO: maybe it is worth to add additional attributes like it is for ember (not confident how to obtain it using Angular) + const all_attrs = { item_guid: resource?.id } as const; + const attributes = Object.fromEntries( + Object.entries(all_attrs).filter(([_, value]: [unknown, unknown]) => typeof value !== 'undefined') + ); + // if we tap one by one through different tabs using Details menu document.title is set as expected + // but when we open it in new tab it is 'OSF' and will be set only after page is loaded + const pageTitle = document.title === 'OSF' ? `OSF | ${resource.title}` : document.title; + return { data: { type: 'counted-usage', attributes: { - item_guid: guid, + ...attributes, action_labels: ['web', 'view'], pageview_info: { page_url: document.URL, - page_title: document.title, + page_title: pageTitle, referer_url: document.referrer, route_name: `angular-osf-web.${routeName}`, }, }, }, }; + } + sendCountedUsage(resource: CurrentResource, route: string): Observable { + const payload = this.getPageviewPayload(resource, route); return this.jsonApiService.post(this.apiDomainUrl, payload); } } diff --git a/src/app/shared/services/resource.service.ts b/src/app/shared/services/resource.service.ts index 839f2194f..d035edb0d 100644 --- a/src/app/shared/services/resource.service.ts +++ b/src/app/shared/services/resource.service.ts @@ -58,6 +58,7 @@ export class ResourceGuidService { wikiEnabled: res.data.attributes.wiki_enabled, permissions: res.data.attributes.current_user_permissions, rootResourceId: res.data.relationships.root?.data?.id, + title: res.data.attributes?.title, }) as CurrentResource ), finalize(() => this.loaderService.hide()) From 0994951045bded4916bda6c53a810ac83ad3de07 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 31 Oct 2025 18:31:53 +0200 Subject: [PATCH 05/14] fix ranamed imports --- src/app/app.component.ts | 3 +++ .../pages/preprint-details/preprint-details.component.ts | 8 -------- src/app/shared/services/analytics.service.ts | 4 ++-- src/app/shared/services/bookmarks.service.ts | 3 +-- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 79fd92df4..c990c530e 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -16,6 +16,9 @@ import { ToastComponent } from './shared/components/toast/toast.component'; import { CustomDialogService } from './shared/services/custom-dialog.service'; import { GoogleTagManagerService } from 'angular-google-tag-manager'; +import { AnalyticsService } from '@osf/shared/services/analytics.service'; +import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; @Component({ selector: 'osf-root', diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index a6d301206..c6d5f3663 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -170,14 +170,6 @@ export class PreprintDetailsComponent implements OnInit, OnDestroy { constructor() { this.helpScoutService.setResourceType('preprint'); - - effect(() => { - const currentPreprint = this.preprint(); - - if (currentPreprint && currentPreprint.isPublic) { - this.analyticsService.sendCountedUsage(currentPreprint.id, 'preprint.detail').subscribe(); - } - }); } private preprintWithdrawableState = computed(() => { diff --git a/src/app/shared/services/analytics.service.ts b/src/app/shared/services/analytics.service.ts index be7cbe405..eae0dcdc7 100644 --- a/src/app/shared/services/analytics.service.ts +++ b/src/app/shared/services/analytics.service.ts @@ -3,8 +3,8 @@ import { Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { CurrentResource } from '@osf/shared/models'; -import { JsonApiService } from '@osf/shared/services'; +import { CurrentResource } from '@osf/shared/models/current-resource.model'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; @Injectable({ providedIn: 'root' }) export class AnalyticsService { diff --git a/src/app/shared/services/bookmarks.service.ts b/src/app/shared/services/bookmarks.service.ts index ccf89670c..9c6165faf 100644 --- a/src/app/shared/services/bookmarks.service.ts +++ b/src/app/shared/services/bookmarks.service.ts @@ -6,8 +6,7 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { ResourceType } from '../enums/resource-type.enum'; import { SparseCollectionsResponseJsonApi } from '../models/collections/collections-json-api.models'; - -import { JsonApiService } from './json-api.service'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; @Injectable({ providedIn: 'root', From 0664d8fed38237d78f567b82e3b938d397797326 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 31 Oct 2025 18:41:46 +0200 Subject: [PATCH 06/14] fix linting --- src/app/app.component.ts | 6 +++--- .../pages/preprint-details/preprint-details.component.ts | 1 - src/app/shared/services/bookmarks.service.ts | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index c990c530e..b5b4b754e 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -9,6 +9,9 @@ import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { GetCurrentUser } from '@core/store/user'; import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { AnalyticsService } from '@osf/shared/services/analytics.service'; +import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; import { ConfirmEmailComponent } from './shared/components/confirm-email/confirm-email.component'; import { FullScreenLoaderComponent } from './shared/components/full-screen-loader/full-screen-loader.component'; @@ -16,9 +19,6 @@ import { ToastComponent } from './shared/components/toast/toast.component'; import { CustomDialogService } from './shared/services/custom-dialog.service'; import { GoogleTagManagerService } from 'angular-google-tag-manager'; -import { AnalyticsService } from '@osf/shared/services/analytics.service'; -import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; -import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; @Component({ selector: 'osf-root', diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index c6d5f3663..73b841f89 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -14,7 +14,6 @@ import { Component, computed, DestroyRef, - effect, HostBinding, inject, OnDestroy, diff --git a/src/app/shared/services/bookmarks.service.ts b/src/app/shared/services/bookmarks.service.ts index 9c6165faf..76c3df211 100644 --- a/src/app/shared/services/bookmarks.service.ts +++ b/src/app/shared/services/bookmarks.service.ts @@ -3,10 +3,10 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; import { ResourceType } from '../enums/resource-type.enum'; import { SparseCollectionsResponseJsonApi } from '../models/collections/collections-json-api.models'; -import { JsonApiService } from '@osf/shared/services/json-api.service'; @Injectable({ providedIn: 'root', From 3bf13cd5d9b4b0adb2833565c7f6187b534761f9 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 3 Nov 2025 13:46:56 +0200 Subject: [PATCH 07/14] call count usage from registration and project details components --- src/app/app.component.ts | 49 +------------------ src/app/features/project/project.component.ts | 26 +++++++++- .../features/registry/registry.component.ts | 20 ++++++-- src/app/shared/services/analytics.service.ts | 10 ++++ 4 files changed, 52 insertions(+), 53 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index b5b4b754e..d51395bcc 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -2,16 +2,13 @@ import { Actions, createDispatchMap, ofActionSuccessful, select } from '@ngxs/st import { filter, take } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { GetCurrentUser } from '@core/store/user'; import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails'; -import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; -import { AnalyticsService } from '@osf/shared/services/analytics.service'; -import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; import { ConfirmEmailComponent } from './shared/components/confirm-email/confirm-email.component'; import { FullScreenLoaderComponent } from './shared/components/full-screen-loader/full-screen-loader.component'; @@ -34,16 +31,9 @@ export class AppComponent implements OnInit { private readonly router = inject(Router); private readonly environment = inject(ENVIRONMENT); private readonly actions$ = inject(Actions); - private readonly analyticsService = inject(AnalyticsService); private readonly actions = createDispatchMap({ getCurrentUser: GetCurrentUser, getEmails: GetEmails }); unverifiedEmails = select(UserEmailsSelectors.getUnverifiedEmails); - currentResource = select(CurrentResourceSelectors.getCurrentResource); - isProjectOrRegistration = computed( - () => - this.currentResource()?.type === CurrentResourceType.Projects || - this.currentResource()?.type === CurrentResourceType.Registrations - ); constructor() { effect(() => { @@ -73,43 +63,6 @@ export class AppComponent implements OnInit { }); }); } - - this.router.events - .pipe( - filter((event) => event instanceof NavigationEnd), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe((event: NavigationEnd) => { - this.sendCountedUsageForRegistrationAndProjects(event.urlAfterRedirects); - }); - } - - sendCountedUsageForRegistrationAndProjects(urlPath: string) { - const detailsPaths = [ - '/overview', - '/metadata/osf', - '/files', - '/resources', - '/wiki', - '/components', - '/contributors', - '/links', - '/analytics', - '/recent-activity', - ]; - // check if it is detail page tab is opened for Project or Registration to log into metrics - if (this.isProjectOrRegistration() && detailsPaths.some((path) => urlPath.endsWith(path))) { - const resource = this.currentResource(); - let route = urlPath.split('/').filter(Boolean).join('.'); - - if (resource?.type) { - route = `${resource?.type}.${route}`; - } - - if (resource) { - this.analyticsService.sendCountedUsage(resource, route).subscribe(); - } - } } private showEmailDialog() { diff --git a/src/app/features/project/project.component.ts b/src/app/features/project/project.component.ts index 9fd8a6ad0..c55d6a6dd 100644 --- a/src/app/features/project/project.component.ts +++ b/src/app/features/project/project.component.ts @@ -1,7 +1,12 @@ -import { ChangeDetectionStrategy, Component, HostBinding, inject, OnDestroy } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { ChangeDetectionStrategy, Component, DestroyRef, HostBinding, inject, OnDestroy } from '@angular/core'; import { HelpScoutService } from '@core/services/help-scout.service'; +import { filter, take } from 'rxjs'; +import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { select } from '@ngxs/store'; +import { CurrentResourceSelectors } from '@shared/stores/current-resource'; +import { AnalyticsService } from '@shared/services/analytics.service'; @Component({ selector: 'osf-project', @@ -14,8 +19,25 @@ export class ProjectComponent implements OnDestroy { private readonly helpScoutService = inject(HelpScoutService); @HostBinding('class') classes = 'flex flex-1 flex-column w-full'; + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly analyticsService = inject(AnalyticsService); + currentResource = select(CurrentResourceSelectors.getCurrentResource); + constructor() { this.helpScoutService.setResourceType('project'); + + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((event: NavigationEnd) => { + this.analyticsService.sendCountedUsageForRegistrationAndProjects( + event.urlAfterRedirects, + this.currentResource() + ); + }); } ngOnDestroy(): void { diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index 43f4ef54c..9e6b20b51 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -1,11 +1,11 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { map, of } from 'rxjs'; +import { filter, map, of } from 'rxjs'; import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, DestroyRef, effect, HostBinding, inject, OnDestroy } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, RouterOutlet } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { ClearCurrentProvider } from '@core/store/provider'; @@ -15,6 +15,7 @@ import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; import { DataciteService } from '@shared/services/datacite/datacite.service'; import { GetRegistryById, RegistryOverviewSelectors } from './store/registry-overview'; +import { CurrentResourceSelectors } from '@shared/stores/current-resource'; @Component({ selector: 'osf-registry', @@ -40,11 +41,12 @@ export class RegistryComponent implements OnDestroy { }); private registryId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); - + readonly currentResource = select(CurrentResourceSelectors.getCurrentResource); readonly registry = select(RegistryOverviewSelectors.getRegistry); readonly isRegistryLoading = select(RegistryOverviewSelectors.isRegistryLoading); readonly registry$ = toObservable(select(RegistryOverviewSelectors.getRegistry)); readonly analyticsService = inject(AnalyticsService); + readonly router = inject(Router); constructor() { effect(() => { @@ -60,6 +62,18 @@ export class RegistryComponent implements OnDestroy { }); this.dataciteService.logIdentifiableView(this.registry$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); + + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((event: NavigationEnd) => { + this.analyticsService.sendCountedUsageForRegistrationAndProjects( + event.urlAfterRedirects, + this.currentResource() + ); + }); } ngOnDestroy(): void { diff --git a/src/app/shared/services/analytics.service.ts b/src/app/shared/services/analytics.service.ts index eae0dcdc7..c362414eb 100644 --- a/src/app/shared/services/analytics.service.ts +++ b/src/app/shared/services/analytics.service.ts @@ -45,4 +45,14 @@ export class AnalyticsService { const payload = this.getPageviewPayload(resource, route); return this.jsonApiService.post(this.apiDomainUrl, payload); } + + sendCountedUsageForRegistrationAndProjects(urlPath: string, resource: CurrentResource | null) { + if (resource) { + let route = urlPath.split('/').filter(Boolean).join('.'); + if (resource?.type) { + route = `${resource?.type}.${route}`; + } + this.sendCountedUsage(resource, route).subscribe(); + } + } } From 940f04cacd84d03e20e704fde65c71940b1ff50f Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 3 Nov 2025 15:03:15 +0200 Subject: [PATCH 08/14] update code --- src/app/features/analytics/analytics.component.ts | 3 ++- src/app/features/project/project.component.ts | 12 +++++++----- src/app/features/registry/registry.component.ts | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index 76c24df94..850a22572 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -176,7 +176,8 @@ export class AnalyticsComponent implements OnInit { const parts = item.path.split('/').filter(Boolean); const resource = parts[1].replace('-', ' ') || 'overview'; // remove redundant 'OSF |' for title beginning - const cleanTitle = item.title === 'OSF' ? item.title : item.title.replace(/^OSF \| /, ''); + let cleanTitle = item.title === 'OSF' ? item.title : item.title.replace(/^OSF \| /, ''); + cleanTitle = cleanTitle.replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>'); // add resource suffix to title to keep it more explicit what detail page is a resource of metrics call return cleanTitle.endsWith(resource) ? cleanTitle : `${cleanTitle} | ${resource}`; }); diff --git a/src/app/features/project/project.component.ts b/src/app/features/project/project.component.ts index c55d6a6dd..bf4fbfd34 100644 --- a/src/app/features/project/project.component.ts +++ b/src/app/features/project/project.component.ts @@ -1,12 +1,14 @@ +import { select } from '@ngxs/store'; + +import { filter } from 'rxjs'; + import { ChangeDetectionStrategy, Component, DestroyRef, HostBinding, inject, OnDestroy } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; import { HelpScoutService } from '@core/services/help-scout.service'; -import { filter, take } from 'rxjs'; -import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { select } from '@ngxs/store'; -import { CurrentResourceSelectors } from '@shared/stores/current-resource'; import { AnalyticsService } from '@shared/services/analytics.service'; +import { CurrentResourceSelectors } from '@shared/stores/current-resource'; @Component({ selector: 'osf-project', diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index 9e6b20b51..495f53ccd 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -13,9 +13,9 @@ import { pathJoin } from '@osf/shared/helpers/path-join.helper'; import { AnalyticsService } from '@osf/shared/services/analytics.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; import { DataciteService } from '@shared/services/datacite/datacite.service'; +import { CurrentResourceSelectors } from '@shared/stores/current-resource'; import { GetRegistryById, RegistryOverviewSelectors } from './store/registry-overview'; -import { CurrentResourceSelectors } from '@shared/stores/current-resource'; @Component({ selector: 'osf-registry', From 005962aaf8cffee1b5d7bb63f292bfe1d1f844fd Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 3 Nov 2025 16:20:53 +0200 Subject: [PATCH 09/14] resolve CR comments --- src/app/features/analytics/analytics.component.ts | 2 -- src/app/shared/services/analytics.service.ts | 3 --- 2 files changed, 5 deletions(-) diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index 850a22572..3ea8cb31c 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -175,10 +175,8 @@ export class AnalyticsComponent implements OnInit { this.popularPagesLabels = analytics.popularPages.map((item) => { const parts = item.path.split('/').filter(Boolean); const resource = parts[1].replace('-', ' ') || 'overview'; - // remove redundant 'OSF |' for title beginning let cleanTitle = item.title === 'OSF' ? item.title : item.title.replace(/^OSF \| /, ''); cleanTitle = cleanTitle.replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>'); - // add resource suffix to title to keep it more explicit what detail page is a resource of metrics call return cleanTitle.endsWith(resource) ? cleanTitle : `${cleanTitle} | ${resource}`; }); diff --git a/src/app/shared/services/analytics.service.ts b/src/app/shared/services/analytics.service.ts index c362414eb..e5b428fa8 100644 --- a/src/app/shared/services/analytics.service.ts +++ b/src/app/shared/services/analytics.service.ts @@ -16,13 +16,10 @@ export class AnalyticsService { } getPageviewPayload(resource: CurrentResource, routeName: string) { - // TODO: maybe it is worth to add additional attributes like it is for ember (not confident how to obtain it using Angular) const all_attrs = { item_guid: resource?.id } as const; const attributes = Object.fromEntries( Object.entries(all_attrs).filter(([_, value]: [unknown, unknown]) => typeof value !== 'undefined') ); - // if we tap one by one through different tabs using Details menu document.title is set as expected - // but when we open it in new tab it is 'OSF' and will be set only after page is loaded const pageTitle = document.title === 'OSF' ? `OSF | ${resource.title}` : document.title; return { data: { From 9f0e4c9f76ee20bf491bb8579f5c30fabd062232 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Mon, 3 Nov 2025 17:20:54 +0200 Subject: [PATCH 10/14] fix Cannot read properties of undefined (reading 'replace') --- src/app/features/analytics/analytics.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index 3ea8cb31c..f27d9b343 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -174,7 +174,7 @@ export class AnalyticsComponent implements OnInit { this.popularPagesLabels = analytics.popularPages.map((item) => { const parts = item.path.split('/').filter(Boolean); - const resource = parts[1].replace('-', ' ') || 'overview'; + const resource = parts[1]?.replace('-', ' ') || 'overview'; let cleanTitle = item.title === 'OSF' ? item.title : item.title.replace(/^OSF \| /, ''); cleanTitle = cleanTitle.replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>'); return cleanTitle.endsWith(resource) ? cleanTitle : `${cleanTitle} | ${resource}`; From d2ec8c74ddacb1da006747b9973cd30f03780efb Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 4 Nov 2025 16:45:43 +0200 Subject: [PATCH 11/14] fix CR and unittests --- src/app/features/project/project.component.spec.ts | 4 +++- src/app/shared/services/bookmarks.service.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/features/project/project.component.spec.ts b/src/app/features/project/project.component.spec.ts index 607a52643..9f7c4cd2a 100644 --- a/src/app/features/project/project.component.spec.ts +++ b/src/app/features/project/project.component.spec.ts @@ -1,3 +1,5 @@ +import { NgxsModule } from '@ngxs/store'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HelpScoutService } from '@core/services/help-scout.service'; @@ -13,7 +15,7 @@ describe('Component: Project', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ProjectComponent, OSFTestingModule], + imports: [ProjectComponent, OSFTestingModule, NgxsModule.forRoot([])], providers: [ { provide: HelpScoutService, diff --git a/src/app/shared/services/bookmarks.service.ts b/src/app/shared/services/bookmarks.service.ts index 76c3df211..ccf89670c 100644 --- a/src/app/shared/services/bookmarks.service.ts +++ b/src/app/shared/services/bookmarks.service.ts @@ -3,11 +3,12 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { JsonApiService } from '@osf/shared/services/json-api.service'; import { ResourceType } from '../enums/resource-type.enum'; import { SparseCollectionsResponseJsonApi } from '../models/collections/collections-json-api.models'; +import { JsonApiService } from './json-api.service'; + @Injectable({ providedIn: 'root', }) From 2bde4f6b470ce3b3cb071f1c4b80841882e653c0 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 4 Nov 2025 18:08:02 +0200 Subject: [PATCH 12/14] resolve CR --- src/app/features/project/project.component.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/features/project/project.component.spec.ts b/src/app/features/project/project.component.spec.ts index 9f7c4cd2a..08c9ec751 100644 --- a/src/app/features/project/project.component.spec.ts +++ b/src/app/features/project/project.component.spec.ts @@ -1,5 +1,3 @@ -import { NgxsModule } from '@ngxs/store'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HelpScoutService } from '@core/services/help-scout.service'; @@ -7,6 +5,8 @@ import { HelpScoutService } from '@core/services/help-scout.service'; import { ProjectComponent } from './project.component'; import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; describe('Component: Project', () => { let component: ProjectComponent; @@ -15,7 +15,7 @@ describe('Component: Project', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ProjectComponent, OSFTestingModule, NgxsModule.forRoot([])], + imports: [ProjectComponent, OSFTestingModule], providers: [ { provide: HelpScoutService, @@ -24,6 +24,9 @@ describe('Component: Project', () => { unsetResourceType: jest.fn(), }, }, + provideMockStore({ + signals: [{ selector: CurrentResourceSelectors.getCurrentResource, value: null }], + }), ], }).compileComponents(); From 95606a03a4a74264e23fb75dfb1d855026883288 Mon Sep 17 00:00:00 2001 From: mkovalua Date: Tue, 4 Nov 2025 18:15:21 +0200 Subject: [PATCH 13/14] fix linter --- src/app/features/project/project.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/project/project.component.spec.ts b/src/app/features/project/project.component.spec.ts index 08c9ec751..e41b875d8 100644 --- a/src/app/features/project/project.component.spec.ts +++ b/src/app/features/project/project.component.spec.ts @@ -1,12 +1,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HelpScoutService } from '@core/services/help-scout.service'; +import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; import { ProjectComponent } from './project.component'; import { OSFTestingModule } from '@testing/osf.testing.module'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; describe('Component: Project', () => { let component: ProjectComponent; From 0af91eaad078ada9c78151832a0c4c6334f7fc1f Mon Sep 17 00:00:00 2001 From: mkovalua Date: Fri, 7 Nov 2025 15:40:40 +0200 Subject: [PATCH 14/14] remove not used attribute in registry.component.ts --- src/app/features/registry/registry.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index 69662e88a..d03ea8a4c 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -68,7 +68,6 @@ export class RegistryComponent implements OnDestroy { readonly isBibliographicContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); readonly license = select(RegistrySelectors.getLicense); readonly isLicenseLoading = select(RegistrySelectors.isLicenseLoading); - readonly isIdentifiersLoading = select(RegistrySelectors.isIdentifiersLoading); private readonly allDataLoaded = computed( () =>