From 35e80e2c04a716924fe554ee63b43c4604cd7fc3 Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Fri, 27 Mar 2026 14:35:41 -0500 Subject: [PATCH 01/15] fix(#10240): fix count target layout overlap with goal label Replace absolute positioning of goal label with flex column layout to prevent overlap with large count numbers. Co-Authored-By: Claude Sonnet 4.6 --- webapp/src/css/targets.less | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/webapp/src/css/targets.less b/webapp/src/css/targets.less index c8ce017bac8..c53873996e0 100644 --- a/webapp/src/css/targets.less +++ b/webapp/src/css/targets.less @@ -69,6 +69,9 @@ position: relative; } .count { + display: flex; + flex-direction: column; + align-items: center; text-align: center; .number { text-align: center; @@ -78,11 +81,7 @@ padding-bottom: 0; } .goal { - position: absolute; - right: 0; - padding-right: 20px; color: @label-color; - top: 15px; p { font-size: @font-small; font-weight: 400; From da8c6a31de873dc97084a039da5d73eb76f7b49e Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Fri, 27 Mar 2026 15:07:51 -0500 Subject: [PATCH 02/15] feat(#10240): hide count number when goal is exceeded with permission Add can_hide_target_count_past_goal permission to control whether the count number is hidden for targets where pass >= goal. Co-Authored-By: Claude Sonnet 4.6 --- .../analytics-targets.component.html | 2 +- .../analytics/analytics-targets.component.ts | 8 +++++ .../analytics-targets.component.spec.ts | 33 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/webapp/src/ts/modules/analytics/analytics-targets.component.html b/webapp/src/ts/modules/analytics/analytics-targets.component.html index 23aa9082810..f756c2245b9 100644 --- a/webapp/src/ts/modules/analytics/analytics-targets.component.html +++ b/webapp/src/ts/modules/analytics/analytics-targets.component.html @@ -39,7 +39,7 @@

{{ 'analytics.target.monthly_goal' | translate }} {{ target.goal | localizeNumber }}

-
{{ target.value?.pass | localizeNumber }}
+
{{ target.value?.pass | localizeNumber }}
diff --git a/webapp/src/ts/modules/analytics/analytics-targets.component.ts b/webapp/src/ts/modules/analytics/analytics-targets.component.ts index 82fb07fe7be..63068b3506f 100644 --- a/webapp/src/ts/modules/analytics/analytics-targets.component.ts +++ b/webapp/src/ts/modules/analytics/analytics-targets.component.ts @@ -4,6 +4,7 @@ import { combineLatest, Subscription } from 'rxjs'; import { RulesEngineService } from '@mm-services/rules-engine.service'; import { PerformanceService } from '@mm-services/performance.service'; +import { AuthService } from '@mm-services/auth.service'; import { GlobalActions } from '@mm-actions/global'; import { NgClass, NgFor, NgIf } from '@angular/common'; import { ErrorLogComponent } from '@mm-components/error-log/error-log.component'; @@ -21,6 +22,8 @@ import { LocalizeNumberPipe } from '@mm-pipes/number.pipe'; import { Selectors } from '@mm-selectors/index'; import { TranslateService } from '@mm-services/translate.service'; +const HIDE_COUNT_PAST_GOAL_PERMISSION = 'can_hide_target_count_past_goal'; + @Component({ templateUrl: './analytics-targets.component.html', imports: [ @@ -42,6 +45,7 @@ export class AnalyticsTargetsComponent implements OnInit, OnDestroy { subscriptions: Subscription = new Subscription(); targets: any[] = []; loading = true; + hideCountWhenGoalMet = false; targetsDisabled = false; errorStack; trackPerformance; @@ -53,6 +57,7 @@ export class AnalyticsTargetsComponent implements OnInit, OnDestroy { constructor( private readonly rulesEngineService: RulesEngineService, private readonly performanceService: PerformanceService, + private readonly authService: AuthService, translateService: TranslateService, private readonly store: Store ) { @@ -63,6 +68,9 @@ export class AnalyticsTargetsComponent implements OnInit, OnDestroy { ngOnInit(): void { this.subscribeToStore(); + this.authService.has(HIDE_COUNT_PAST_GOAL_PERMISSION).then(has => { + this.hideCountWhenGoalMet = has; + }); this.getTargets(AnalyticsSidebarFilterComponent.DEFAULT_REPORTING_PERIOD); } diff --git a/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts b/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts index 021d0bf0994..9dc14dcfeb1 100644 --- a/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts +++ b/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts @@ -18,6 +18,8 @@ import { UserSettingsService } from '@mm-services/user-settings.service'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { SettingsService } from '@mm-services/settings.service'; import { TelemetryService } from '@mm-services/telemetry.service'; +import { AuthService } from '@mm-services/auth.service'; +import { ResourceIconsService } from '@mm-services/resource-icons.service'; import { Selectors } from '@mm-selectors/index'; describe('AnalyticsTargetsComponent', () => { @@ -28,6 +30,8 @@ describe('AnalyticsTargetsComponent', () => { let stopPerformanceTrackStub; let sessionService; let userSettingsService; + let authService; + let resourceIconsService; let globalActions; let store: MockStore; @@ -85,6 +89,14 @@ describe('AnalyticsTargetsComponent', () => { userCtx: sinon.stub() }; + authService = { + has: sinon.stub().resolves(false), + }; + + resourceIconsService = { + getImg: sinon.stub().returns(''), + }; + return TestBed .configureTestingModule({ imports: [ @@ -98,6 +110,8 @@ describe('AnalyticsTargetsComponent', () => { { provide: RulesEngineService, useValue: rulesEngineService }, { provide: PerformanceService, useValue: performanceService }, { provide: SessionService, useValue: sessionService }, + { provide: AuthService, useValue: authService }, + { provide: ResourceIconsService, useValue: resourceIconsService }, { provide: UserSettingsService, useValue: userSettingsService }, { provide: ContactTypesService, useValue: contactTypesService }, { provide: SettingsService, useValue: settingsService }, @@ -135,6 +149,7 @@ describe('AnalyticsTargetsComponent', () => { it('should set up component when rules engine is not enabled', fakeAsync(() => { sinon.reset(); + authService.has.resolves(false); rulesEngineService.isEnabled.resolves(false); component.ngOnInit(); @@ -154,6 +169,7 @@ describe('AnalyticsTargetsComponent', () => { it('should fetch targets when rules engine is enabled', fakeAsync(() => { sinon.reset(); + authService.has.resolves(false); rulesEngineService.isEnabled.resolves(true); rulesEngineService.fetchTargets.resolves([{ id: 'target1' }, { id: 'target2' }]); @@ -177,6 +193,7 @@ describe('AnalyticsTargetsComponent', () => { it('should filter targets to visible ones', fakeAsync(() => { sinon.reset(); + authService.has.resolves(false); rulesEngineService.isEnabled.resolves(true); const targets = [ { id: 'target1' }, @@ -204,6 +221,7 @@ describe('AnalyticsTargetsComponent', () => { it('should catch rules engine errors', fakeAsync(() => { sinon.reset(); + authService.has.resolves(false); rulesEngineService.isEnabled.rejects('error'); const consoleErrorMock = sinon.stub(console, 'error'); @@ -277,6 +295,21 @@ describe('AnalyticsTargetsComponent', () => { expect(globalActions.setShowContent.calledOnceWithExactly(true)).to.be.true; }); + it('should hide count number when permission is granted and goal is met', fakeAsync(() => { + sinon.reset(); + authService.has.resolves(true); + rulesEngineService.isEnabled.resolves(true); + rulesEngineService.fetchTargets.resolves([ + { id: 'target1', type: 'count', goal: 10, value: { pass: 15, total: 15 } }, + ]); + + component.ngOnInit(); + tick(50); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.count .number')).to.be.null; + })); + it(`should reset to the default reporting period when showContent is set to false`, fakeAsync(() => { sinon.reset(); rulesEngineService.isEnabled.resolves(true); From 13d29191c554b982b7bdee67124e7fbfdf1accaf Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Fri, 27 Mar 2026 15:17:36 -0500 Subject: [PATCH 03/15] feat(#10240): test count number visibility for different permission and goal combinations Co-Authored-By: Claude Sonnet 4.6 --- .../analytics-targets.component.spec.ts | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts b/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts index 9dc14dcfeb1..4b5034575a0 100644 --- a/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts +++ b/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts @@ -295,7 +295,7 @@ describe('AnalyticsTargetsComponent', () => { expect(globalActions.setShowContent.calledOnceWithExactly(true)).to.be.true; }); - it('should hide count number when permission is granted and goal is met', fakeAsync(() => { + it('should hide count number when permission is granted and goal is exceeded', fakeAsync(() => { sinon.reset(); authService.has.resolves(true); rulesEngineService.isEnabled.resolves(true); @@ -310,6 +310,51 @@ describe('AnalyticsTargetsComponent', () => { expect(fixture.nativeElement.querySelector('.count .number')).to.be.null; })); + it('should hide count number when permission is granted and count equals goal', fakeAsync(() => { + sinon.reset(); + authService.has.resolves(true); + rulesEngineService.isEnabled.resolves(true); + rulesEngineService.fetchTargets.resolves([ + { id: 'target1', type: 'count', goal: 10, value: { pass: 10, total: 10 } }, + ]); + + component.ngOnInit(); + tick(50); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.count .number')).to.be.null; + })); + + it('should show count number when permission is granted but goal is not yet met', fakeAsync(() => { + sinon.reset(); + authService.has.resolves(true); + rulesEngineService.isEnabled.resolves(true); + rulesEngineService.fetchTargets.resolves([ + { id: 'target1', type: 'count', goal: 10, value: { pass: 5, total: 5 } }, + ]); + + component.ngOnInit(); + tick(50); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.count .number')).to.not.be.null; + })); + + it('should show count number when goal is met but permission is not granted', fakeAsync(() => { + sinon.reset(); + authService.has.resolves(false); + rulesEngineService.isEnabled.resolves(true); + rulesEngineService.fetchTargets.resolves([ + { id: 'target1', type: 'count', goal: 10, value: { pass: 15, total: 15 } }, + ]); + + component.ngOnInit(); + tick(50); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.count .number')).to.not.be.null; + })); + it(`should reset to the default reporting period when showContent is set to false`, fakeAsync(() => { sinon.reset(); rulesEngineService.isEnabled.resolves(true); From 5b305d373202eb9a74c6392a9eeac66937bdbef5 Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Fri, 27 Mar 2026 15:20:44 -0500 Subject: [PATCH 04/15] feat(#10240): assert permission string when hiding count past goal Co-Authored-By: Claude Sonnet 4.6 --- .../ts/modules/analytics/analytics-targets.component.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts b/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts index 4b5034575a0..2556c4ea446 100644 --- a/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts +++ b/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts @@ -307,6 +307,7 @@ describe('AnalyticsTargetsComponent', () => { tick(50); fixture.detectChanges(); + expect(authService.has.calledOnceWithExactly('can_hide_target_count_past_goal')).to.be.true; expect(fixture.nativeElement.querySelector('.count .number')).to.be.null; })); From 95e1d0f17d4565e361d380c2d057e538037f96ac Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Fri, 27 Mar 2026 15:23:52 -0500 Subject: [PATCH 05/15] feat(#10240): add can_hide_target_count_past_goal permission to default config Co-Authored-By: Claude Sonnet 4.6 --- config/default/app_settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/config/default/app_settings.json b/config/default/app_settings.json index fd2d250580f..3d611276e36 100644 --- a/config/default/app_settings.json +++ b/config/default/app_settings.json @@ -283,6 +283,7 @@ "can_view_old_navigation": [], "can_default_facility_filter": [], "can_have_multiple_places": [], + "can_hide_target_count_past_goal": [], "can_skip_password_change": [], "can_get_task_notifications": [ "chw", From e2ef81638b5e9c4b43b064e075ab02e27f5ec37b Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Fri, 27 Mar 2026 20:47:06 -0500 Subject: [PATCH 06/15] feat(#10240): test that count is shown when target has no goal Co-Authored-By: Claude Sonnet 4.6 --- .../analytics/analytics-targets.component.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts b/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts index 2556c4ea446..102013cf51a 100644 --- a/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts +++ b/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts @@ -341,6 +341,21 @@ describe('AnalyticsTargetsComponent', () => { expect(fixture.nativeElement.querySelector('.count .number')).to.not.be.null; })); + it('should show count number when permission is granted but target has no goal', fakeAsync(() => { + sinon.reset(); + authService.has.resolves(true); + rulesEngineService.isEnabled.resolves(true); + rulesEngineService.fetchTargets.resolves([ + { id: 'target1', type: 'count', goal: -1, value: { pass: 15, total: 15 } }, + ]); + + component.ngOnInit(); + tick(50); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.count .number')).to.not.be.null; + })); + it('should show count number when goal is met but permission is not granted', fakeAsync(() => { sinon.reset(); authService.has.resolves(false); From 8247ce076961b0f645db2e56e823b95397be969f Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Mon, 30 Mar 2026 15:28:36 -0500 Subject: [PATCH 07/15] feat(#10240): use async/await for permission check in ngOnInit Co-Authored-By: Diana Barsan <35681649+dianabarsan@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 --- .../src/ts/modules/analytics/analytics-targets.component.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/webapp/src/ts/modules/analytics/analytics-targets.component.ts b/webapp/src/ts/modules/analytics/analytics-targets.component.ts index 63068b3506f..9e576be823b 100644 --- a/webapp/src/ts/modules/analytics/analytics-targets.component.ts +++ b/webapp/src/ts/modules/analytics/analytics-targets.component.ts @@ -66,11 +66,9 @@ export class AnalyticsTargetsComponent implements OnInit, OnDestroy { this.PREVIOUS_TARGETS_TITLE = translateService.instant('targets.last_month.subtitle'); } - ngOnInit(): void { + async ngOnInit(): Promise { this.subscribeToStore(); - this.authService.has(HIDE_COUNT_PAST_GOAL_PERMISSION).then(has => { - this.hideCountWhenGoalMet = has; - }); + this.hideCountWhenGoalMet = await this.authService.has(HIDE_COUNT_PAST_GOAL_PERMISSION); this.getTargets(AnalyticsSidebarFilterComponent.DEFAULT_REPORTING_PERIOD); } From 123bb09ff7e77a34e43b76ce2e846cdcb6fada28 Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Tue, 31 Mar 2026 12:50:18 -0500 Subject: [PATCH 08/15] Revert feature flag additions This reverts the changes made in the following commits: - da8c6a31d - 13d29191c - 5b305d373 - 95e1d0f17 - e2ef81638 - 8247ce076 --- config/default/app_settings.json | 1 - .../analytics-targets.component.html | 2 +- .../analytics/analytics-targets.component.ts | 8 +- .../analytics-targets.component.spec.ts | 94 ------------------- 4 files changed, 2 insertions(+), 103 deletions(-) diff --git a/config/default/app_settings.json b/config/default/app_settings.json index 3d611276e36..fd2d250580f 100644 --- a/config/default/app_settings.json +++ b/config/default/app_settings.json @@ -283,7 +283,6 @@ "can_view_old_navigation": [], "can_default_facility_filter": [], "can_have_multiple_places": [], - "can_hide_target_count_past_goal": [], "can_skip_password_change": [], "can_get_task_notifications": [ "chw", diff --git a/webapp/src/ts/modules/analytics/analytics-targets.component.html b/webapp/src/ts/modules/analytics/analytics-targets.component.html index f756c2245b9..23aa9082810 100644 --- a/webapp/src/ts/modules/analytics/analytics-targets.component.html +++ b/webapp/src/ts/modules/analytics/analytics-targets.component.html @@ -39,7 +39,7 @@

{{ 'analytics.target.monthly_goal' | translate }} {{ target.goal | localizeNumber }}

-
{{ target.value?.pass | localizeNumber }}
+
{{ target.value?.pass | localizeNumber }}
diff --git a/webapp/src/ts/modules/analytics/analytics-targets.component.ts b/webapp/src/ts/modules/analytics/analytics-targets.component.ts index 9e576be823b..82fb07fe7be 100644 --- a/webapp/src/ts/modules/analytics/analytics-targets.component.ts +++ b/webapp/src/ts/modules/analytics/analytics-targets.component.ts @@ -4,7 +4,6 @@ import { combineLatest, Subscription } from 'rxjs'; import { RulesEngineService } from '@mm-services/rules-engine.service'; import { PerformanceService } from '@mm-services/performance.service'; -import { AuthService } from '@mm-services/auth.service'; import { GlobalActions } from '@mm-actions/global'; import { NgClass, NgFor, NgIf } from '@angular/common'; import { ErrorLogComponent } from '@mm-components/error-log/error-log.component'; @@ -22,8 +21,6 @@ import { LocalizeNumberPipe } from '@mm-pipes/number.pipe'; import { Selectors } from '@mm-selectors/index'; import { TranslateService } from '@mm-services/translate.service'; -const HIDE_COUNT_PAST_GOAL_PERMISSION = 'can_hide_target_count_past_goal'; - @Component({ templateUrl: './analytics-targets.component.html', imports: [ @@ -45,7 +42,6 @@ export class AnalyticsTargetsComponent implements OnInit, OnDestroy { subscriptions: Subscription = new Subscription(); targets: any[] = []; loading = true; - hideCountWhenGoalMet = false; targetsDisabled = false; errorStack; trackPerformance; @@ -57,7 +53,6 @@ export class AnalyticsTargetsComponent implements OnInit, OnDestroy { constructor( private readonly rulesEngineService: RulesEngineService, private readonly performanceService: PerformanceService, - private readonly authService: AuthService, translateService: TranslateService, private readonly store: Store ) { @@ -66,9 +61,8 @@ export class AnalyticsTargetsComponent implements OnInit, OnDestroy { this.PREVIOUS_TARGETS_TITLE = translateService.instant('targets.last_month.subtitle'); } - async ngOnInit(): Promise { + ngOnInit(): void { this.subscribeToStore(); - this.hideCountWhenGoalMet = await this.authService.has(HIDE_COUNT_PAST_GOAL_PERMISSION); this.getTargets(AnalyticsSidebarFilterComponent.DEFAULT_REPORTING_PERIOD); } diff --git a/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts b/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts index 102013cf51a..021d0bf0994 100644 --- a/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts +++ b/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts @@ -18,8 +18,6 @@ import { UserSettingsService } from '@mm-services/user-settings.service'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { SettingsService } from '@mm-services/settings.service'; import { TelemetryService } from '@mm-services/telemetry.service'; -import { AuthService } from '@mm-services/auth.service'; -import { ResourceIconsService } from '@mm-services/resource-icons.service'; import { Selectors } from '@mm-selectors/index'; describe('AnalyticsTargetsComponent', () => { @@ -30,8 +28,6 @@ describe('AnalyticsTargetsComponent', () => { let stopPerformanceTrackStub; let sessionService; let userSettingsService; - let authService; - let resourceIconsService; let globalActions; let store: MockStore; @@ -89,14 +85,6 @@ describe('AnalyticsTargetsComponent', () => { userCtx: sinon.stub() }; - authService = { - has: sinon.stub().resolves(false), - }; - - resourceIconsService = { - getImg: sinon.stub().returns(''), - }; - return TestBed .configureTestingModule({ imports: [ @@ -110,8 +98,6 @@ describe('AnalyticsTargetsComponent', () => { { provide: RulesEngineService, useValue: rulesEngineService }, { provide: PerformanceService, useValue: performanceService }, { provide: SessionService, useValue: sessionService }, - { provide: AuthService, useValue: authService }, - { provide: ResourceIconsService, useValue: resourceIconsService }, { provide: UserSettingsService, useValue: userSettingsService }, { provide: ContactTypesService, useValue: contactTypesService }, { provide: SettingsService, useValue: settingsService }, @@ -149,7 +135,6 @@ describe('AnalyticsTargetsComponent', () => { it('should set up component when rules engine is not enabled', fakeAsync(() => { sinon.reset(); - authService.has.resolves(false); rulesEngineService.isEnabled.resolves(false); component.ngOnInit(); @@ -169,7 +154,6 @@ describe('AnalyticsTargetsComponent', () => { it('should fetch targets when rules engine is enabled', fakeAsync(() => { sinon.reset(); - authService.has.resolves(false); rulesEngineService.isEnabled.resolves(true); rulesEngineService.fetchTargets.resolves([{ id: 'target1' }, { id: 'target2' }]); @@ -193,7 +177,6 @@ describe('AnalyticsTargetsComponent', () => { it('should filter targets to visible ones', fakeAsync(() => { sinon.reset(); - authService.has.resolves(false); rulesEngineService.isEnabled.resolves(true); const targets = [ { id: 'target1' }, @@ -221,7 +204,6 @@ describe('AnalyticsTargetsComponent', () => { it('should catch rules engine errors', fakeAsync(() => { sinon.reset(); - authService.has.resolves(false); rulesEngineService.isEnabled.rejects('error'); const consoleErrorMock = sinon.stub(console, 'error'); @@ -295,82 +277,6 @@ describe('AnalyticsTargetsComponent', () => { expect(globalActions.setShowContent.calledOnceWithExactly(true)).to.be.true; }); - it('should hide count number when permission is granted and goal is exceeded', fakeAsync(() => { - sinon.reset(); - authService.has.resolves(true); - rulesEngineService.isEnabled.resolves(true); - rulesEngineService.fetchTargets.resolves([ - { id: 'target1', type: 'count', goal: 10, value: { pass: 15, total: 15 } }, - ]); - - component.ngOnInit(); - tick(50); - fixture.detectChanges(); - - expect(authService.has.calledOnceWithExactly('can_hide_target_count_past_goal')).to.be.true; - expect(fixture.nativeElement.querySelector('.count .number')).to.be.null; - })); - - it('should hide count number when permission is granted and count equals goal', fakeAsync(() => { - sinon.reset(); - authService.has.resolves(true); - rulesEngineService.isEnabled.resolves(true); - rulesEngineService.fetchTargets.resolves([ - { id: 'target1', type: 'count', goal: 10, value: { pass: 10, total: 10 } }, - ]); - - component.ngOnInit(); - tick(50); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('.count .number')).to.be.null; - })); - - it('should show count number when permission is granted but goal is not yet met', fakeAsync(() => { - sinon.reset(); - authService.has.resolves(true); - rulesEngineService.isEnabled.resolves(true); - rulesEngineService.fetchTargets.resolves([ - { id: 'target1', type: 'count', goal: 10, value: { pass: 5, total: 5 } }, - ]); - - component.ngOnInit(); - tick(50); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('.count .number')).to.not.be.null; - })); - - it('should show count number when permission is granted but target has no goal', fakeAsync(() => { - sinon.reset(); - authService.has.resolves(true); - rulesEngineService.isEnabled.resolves(true); - rulesEngineService.fetchTargets.resolves([ - { id: 'target1', type: 'count', goal: -1, value: { pass: 15, total: 15 } }, - ]); - - component.ngOnInit(); - tick(50); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('.count .number')).to.not.be.null; - })); - - it('should show count number when goal is met but permission is not granted', fakeAsync(() => { - sinon.reset(); - authService.has.resolves(false); - rulesEngineService.isEnabled.resolves(true); - rulesEngineService.fetchTargets.resolves([ - { id: 'target1', type: 'count', goal: 10, value: { pass: 15, total: 15 } }, - ]); - - component.ngOnInit(); - tick(50); - fixture.detectChanges(); - - expect(fixture.nativeElement.querySelector('.count .number')).to.not.be.null; - })); - it(`should reset to the default reporting period when showContent is set to false`, fakeAsync(() => { sinon.reset(); rulesEngineService.isEnabled.resolves(true); From 9c6aa21e826b0b840df679fb1cabc7be5cb129ca Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Tue, 31 Mar 2026 13:10:36 -0500 Subject: [PATCH 09/15] feat(#10240): cap displayed count at goal when limit_count_to_goal is set Co-Authored-By: Claude Sonnet 4.6 --- .../analytics-targets.component.html | 2 +- .../analytics/analytics-targets.component.ts | 7 ++ .../analytics-targets.component.spec.ts | 72 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/webapp/src/ts/modules/analytics/analytics-targets.component.html b/webapp/src/ts/modules/analytics/analytics-targets.component.html index 23aa9082810..31cd186533b 100644 --- a/webapp/src/ts/modules/analytics/analytics-targets.component.html +++ b/webapp/src/ts/modules/analytics/analytics-targets.component.html @@ -39,7 +39,7 @@

{{ 'analytics.target.monthly_goal' | translate }} {{ target.goal | localizeNumber }}

-
{{ target.value?.pass | localizeNumber }}
+
{{ getDisplayCount(target) | localizeNumber }}
diff --git a/webapp/src/ts/modules/analytics/analytics-targets.component.ts b/webapp/src/ts/modules/analytics/analytics-targets.component.ts index 82fb07fe7be..7b1e4e58f51 100644 --- a/webapp/src/ts/modules/analytics/analytics-targets.component.ts +++ b/webapp/src/ts/modules/analytics/analytics-targets.component.ts @@ -133,4 +133,11 @@ export class AnalyticsTargetsComponent implements OnInit, OnDestroy { }); }); } + + getDisplayCount(target: any): number { + if (target.limit_count_to_goal && target.goal >= 0 && target.value?.pass >= target.goal) { + return target.goal; + } + return target.value?.pass; + } } diff --git a/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts b/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts index 021d0bf0994..29d53ac6b3a 100644 --- a/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts +++ b/webapp/tests/karma/ts/modules/analytics/analytics-targets.component.spec.ts @@ -19,6 +19,7 @@ import { ContactTypesService } from '@mm-services/contact-types.service'; import { SettingsService } from '@mm-services/settings.service'; import { TelemetryService } from '@mm-services/telemetry.service'; import { Selectors } from '@mm-selectors/index'; +import { ResourceIconsService } from '@mm-services/resource-icons.service'; describe('AnalyticsTargetsComponent', () => { let component: AnalyticsTargetsComponent; @@ -102,6 +103,7 @@ describe('AnalyticsTargetsComponent', () => { { provide: ContactTypesService, useValue: contactTypesService }, { provide: SettingsService, useValue: settingsService }, { provide: TelemetryService, useValue: telemetryService }, + { provide: ResourceIconsService, useValue: { getImg: sinon.stub().returns('') } }, ] }) .compileComponents() @@ -323,4 +325,74 @@ describe('AnalyticsTargetsComponent', () => { expect(globalActions.setTitle.notCalled).to.be.true; expect(globalActions.setShowContent.calledOnceWithExactly(false)).to.be.true; })); + + it('should display goal as count number when limit_count_to_goal is set and count exceeds goal', fakeAsync(() => { + sinon.reset(); + rulesEngineService.isEnabled.resolves(true); + rulesEngineService.fetchTargets.resolves([ + { id: 'target1', type: 'count', goal: 10, limit_count_to_goal: true, value: { pass: 15, total: 15 } }, + ]); + + component.ngOnInit(); + tick(50); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.count .number').innerText).to.equal('10'); + })); + + it('should display goal as count number when limit_count_to_goal is set and count equals goal', fakeAsync(() => { + sinon.reset(); + rulesEngineService.isEnabled.resolves(true); + rulesEngineService.fetchTargets.resolves([ + { id: 'target1', type: 'count', goal: 10, limit_count_to_goal: true, value: { pass: 10, total: 10 } }, + ]); + + component.ngOnInit(); + tick(50); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.count .number').innerText).to.equal('10'); + })); + + it('should display actual count when limit_count_to_goal is set but count is below goal', fakeAsync(() => { + sinon.reset(); + rulesEngineService.isEnabled.resolves(true); + rulesEngineService.fetchTargets.resolves([ + { id: 'target1', type: 'count', goal: 10, limit_count_to_goal: true, value: { pass: 5, total: 5 } }, + ]); + + component.ngOnInit(); + tick(50); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.count .number').innerText).to.equal('5'); + })); + + it('should display actual count when limit_count_to_goal is not set and count exceeds goal', fakeAsync(() => { + sinon.reset(); + rulesEngineService.isEnabled.resolves(true); + rulesEngineService.fetchTargets.resolves([ + { id: 'target1', type: 'count', goal: 10, value: { pass: 15, total: 15 } }, + ]); + + component.ngOnInit(); + tick(50); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.count .number').innerText).to.equal('15'); + })); + + it('should display actual count when limit_count_to_goal is set but target has no goal', fakeAsync(() => { + sinon.reset(); + rulesEngineService.isEnabled.resolves(true); + rulesEngineService.fetchTargets.resolves([ + { id: 'target1', type: 'count', goal: -1, limit_count_to_goal: true, value: { pass: 15, total: 15 } }, + ]); + + component.ngOnInit(); + tick(50); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.count .number').innerText).to.equal('15'); + })); }); From b0347cab475a6e2d5358daf961d647f15892e556 Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Tue, 31 Mar 2026 13:26:39 -0500 Subject: [PATCH 10/15] fix(#10240): align count numbers across targets with and without goals Co-Authored-By: Claude Sonnet 4.6 --- webapp/src/css/targets.less | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webapp/src/css/targets.less b/webapp/src/css/targets.less index c53873996e0..59fe3d14b31 100644 --- a/webapp/src/css/targets.less +++ b/webapp/src/css/targets.less @@ -72,7 +72,9 @@ display: flex; flex-direction: column; align-items: center; + justify-content: flex-end; text-align: center; + min-height: 74px; .number { text-align: center; font-size: 48px; From fc76773e98c3f80950a5f659d74231c53ba0ea33 Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Tue, 31 Mar 2026 14:55:56 -0500 Subject: [PATCH 11/15] feat(#10240): pass limit_count_to_goal through rules engine target aggregation Co-Authored-By: Claude Sonnet 4.6 --- shared-libs/rules-engine/src/target-state.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared-libs/rules-engine/src/target-state.js b/shared-libs/rules-engine/src/target-state.js index cab590e9040..10d88886683 100644 --- a/shared-libs/rules-engine/src/target-state.js +++ b/shared-libs/rules-engine/src/target-state.js @@ -205,7 +205,7 @@ module.exports = { const aggregateTarget = target => { const aggregated = pick( target, - ['id', 'type', 'goal', 'translation_key', 'name', 'icon', 'subtitle_translation_key', 'visible'] + ['id', 'type', 'goal', 'translation_key', 'name', 'icon', 'subtitle_translation_key', 'visible', 'limit_count_to_goal'] ); aggregated.value = scoreTarget(target); From 588de7920eacfaedfd01480538f41da419de4084 Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Wed, 1 Apr 2026 08:27:46 -0500 Subject: [PATCH 12/15] fix(#10240): fix line length linting error in target-state.js --- shared-libs/rules-engine/src/target-state.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shared-libs/rules-engine/src/target-state.js b/shared-libs/rules-engine/src/target-state.js index 10d88886683..a335e4926f2 100644 --- a/shared-libs/rules-engine/src/target-state.js +++ b/shared-libs/rules-engine/src/target-state.js @@ -205,7 +205,8 @@ module.exports = { const aggregateTarget = target => { const aggregated = pick( target, - ['id', 'type', 'goal', 'translation_key', 'name', 'icon', 'subtitle_translation_key', 'visible', 'limit_count_to_goal'] + ['id', 'type', 'goal', 'translation_key', 'name', 'icon', 'subtitle_translation_key', 'visible', + 'limit_count_to_goal'] ); aggregated.value = scoreTarget(target); From a0c6c04352b253a7d3403fd04bfadfc076d10319 Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Fri, 3 Apr 2026 13:38:46 -0500 Subject: [PATCH 13/15] chore(#10240): install cht-conf branch with limit_count_to_goal support --- package-lock.json | 427 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 +- 2 files changed, 425 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 85dd8d4f32b..62bf1a74b7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,7 +113,7 @@ "chai-shallow-deep-equal": "^1.4.6", "chai-things": "^0.2.0", "chokidar": "^4.0.3", - "cht-conf": "^6.0.2", + "cht-conf": "github:cliftonmcintosh/cht-conf#feat/10240-hide-target-counts-past-goal", "cht-conf-test-harness": "^5.0.3", "clean-css-cli": "^5.6.2", "couchdb-compile": "^1.11.2", @@ -12172,6 +12172,49 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/appium-uiautomator2-driver/node_modules/@appium/docutils": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@appium/docutils/-/docutils-2.2.1.tgz", + "integrity": "sha512-mhyQuvQUGepH+MEOGt3ixTM5q1NNVsxr+jvz/6t7KFJm8ElRlfyGGWcA3fqvnXm72hm3rzhh2t13sLct7qWUBg==", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "@appium/support": "^7.0.5", + "consola": "3.4.2", + "diff": "8.0.3", + "lilconfig": "3.1.3", + "lodash": "4.17.23", + "package-directory": "8.1.0", + "read-pkg": "10.0.0", + "teen_process": "4.0.8", + "type-fest": "5.4.1", + "yaml": "2.8.2", + "yargs": "18.0.0", + "yargs-parser": "22.0.0" + }, + "bin": { + "appium-docs": "bin/appium-docs.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/@appium/docutils/node_modules/teen_process": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/teen_process/-/teen_process-4.0.8.tgz", + "integrity": "sha512-0DTX2KfgVOr6+8TVmheEdiJHZ/bPOPeJuX0yvv5VOX3x+OFteNkmWkI+hX6zTkzxjddrktsrXkacfS2Gom1YyA==", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21", + "shell-quote": "^1.8.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/@appium/logger": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@appium/logger/-/logger-2.0.4.tgz", @@ -12346,6 +12389,16 @@ "node": ">=0.1.90" } }, + "node_modules/appium-uiautomator2-driver/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -12785,6 +12838,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/appium-uiautomator2-driver/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "extraneous": true, + "license": "Python-2.0" + }, "node_modules/appium-uiautomator2-driver/node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -13026,6 +13086,92 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/appium-uiautomator2-driver/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "extraneous": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/appium-uiautomator2-driver/node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -13083,6 +13229,16 @@ "node": ">= 14" } }, + "node_modules/appium-uiautomator2-driver/node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -13280,6 +13436,16 @@ "license": "MIT", "optional": true }, + "node_modules/appium-uiautomator2-driver/node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "extraneous": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -13382,6 +13548,16 @@ "node": ">= 0.4" } }, + "node_modules/appium-uiautomator2-driver/node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -13685,6 +13861,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/appium-uiautomator2-driver/node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "extraneous": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -13839,6 +14038,16 @@ "license": "MIT", "optional": true }, + "node_modules/appium-uiautomator2-driver/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -14246,6 +14455,19 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/appium-uiautomator2-driver/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/lockfile": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/lockfile/-/lockfile-1.0.4.tgz", @@ -14400,6 +14622,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/appium-uiautomator2-driver/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -14594,6 +14826,22 @@ "wrappy": "1" } }, + "node_modules/appium-uiautomator2-driver/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/p-limit": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", @@ -15634,6 +15882,13 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/appium-uiautomator2-driver/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "extraneous": true, + "license": "0BSD" + }, "node_modules/appium-uiautomator2-driver/node_modules/type-fest": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.1.tgz", @@ -15774,6 +16029,24 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/appium-uiautomator2-driver/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", @@ -15793,6 +16066,60 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/appium-uiautomator2-driver/node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/appium-uiautomator2-driver/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -15842,6 +16169,101 @@ "node": ">=0.6.0" } }, + "node_modules/appium-uiautomator2-driver/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "extraneous": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "extraneous": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "extraneous": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/yargs/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/appium-uiautomator2-driver/node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/yargs/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/yauzl": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz", @@ -18297,8 +18719,7 @@ }, "node_modules/cht-conf": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/cht-conf/-/cht-conf-6.0.2.tgz", - "integrity": "sha512-HNHtqpJ+CafJIr3+3pvhHnVfLoeqXQ93eeQDe2P6ZT3IS5mwZ1Oy34o1R99bImR58u+pbx8D2sPshdu0MHULMQ==", + "resolved": "git+ssh://git@github.com/cliftonmcintosh/cht-conf.git#97842d4e1496de03c7af3546e0196be9c47e2aae", "dev": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 7579026f49f..240fb7388ca 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "chai-shallow-deep-equal": "^1.4.6", "chai-things": "^0.2.0", "chokidar": "^4.0.3", - "cht-conf": "^6.0.2", + "cht-conf": "github:cliftonmcintosh/cht-conf#feat/10240-hide-target-counts-past-goal", "cht-conf-test-harness": "^5.0.3", "clean-css-cli": "^5.6.2", "couchdb-compile": "^1.11.2", From 470c115fc8a9b4dbc85d94066ac56eb5796d5b3a Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Fri, 3 Apr 2026 13:42:39 -0500 Subject: [PATCH 14/15] feat(#10240): add e2e test for limit_count_to_goal target display Co-Authored-By: Claude Sonnet 4.6 --- tests/e2e/default/targets/analytics.wdio-spec.js | 14 ++++++++++++++ .../targets/config/targets-limit-count-config.js | 12 ++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 tests/e2e/default/targets/config/targets-limit-count-config.js diff --git a/tests/e2e/default/targets/analytics.wdio-spec.js b/tests/e2e/default/targets/analytics.wdio-spec.js index 30505312d5a..c970128e7ff 100644 --- a/tests/e2e/default/targets/analytics.wdio-spec.js +++ b/tests/e2e/default/targets/analytics.wdio-spec.js @@ -152,6 +152,20 @@ describe('Targets', () => { await browser.waitUntil(async () => (await analyticsPage.noAdminTargets().isDisplayed()) === true); }); + it('should display goal as count when limit_count_to_goal is set and count exceeds goal', async () => { + const settings = await compileTargets('targets-limit-count-config.js'); + await utils.updateSettings(settings, { ignoreReload: true, sync: true, refresh: true, revert: true }); + + await analyticsPage.goToTargets(); + await commonPage.waitForLoaders(); + + // The test setup includes person contacts, so the rules engine will calculate a pass count. + // goal=1 is set low enough that it will be exceeded, so limit_count_to_goal should cap the display at 1. + const targets = await analyticsPage.getTargets(); + const activePregnancies = targets.find(t => t.title === 'Active pregnancies'); + expect(activePregnancies.count).to.equal('1'); + }); + it('should show error message for bad config', async () => { const settings = await compileTargets('targets-error-config.js'); await utils.updateSettings(settings, { ignoreReload: true, sync: true, refresh: true, revert: true }); diff --git a/tests/e2e/default/targets/config/targets-limit-count-config.js b/tests/e2e/default/targets/config/targets-limit-count-config.js new file mode 100644 index 00000000000..ecc4e8cb037 --- /dev/null +++ b/tests/e2e/default/targets/config/targets-limit-count-config.js @@ -0,0 +1,12 @@ +module.exports = [ + { + id: 'active-pregnancies', + translation_key: 'targets.anc.active_pregnancies.title', + type: 'count', + goal: 1, + limit_count_to_goal: true, + appliesTo: 'contacts', + appliesToType: ['person'], + date: 'now', + }, +]; From e7a88310d08246aa2184aae77fac4e673103f859 Mon Sep 17 00:00:00 2001 From: Clifton McIntosh Date: Fri, 3 Apr 2026 13:53:24 -0500 Subject: [PATCH 15/15] fix(#10240): move goal text below count number to align display across target cards Co-Authored-By: Claude Sonnet 4.6 --- webapp/src/css/targets.less | 2 -- .../src/ts/modules/analytics/analytics-targets.component.html | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/webapp/src/css/targets.less b/webapp/src/css/targets.less index 59fe3d14b31..c53873996e0 100644 --- a/webapp/src/css/targets.less +++ b/webapp/src/css/targets.less @@ -72,9 +72,7 @@ display: flex; flex-direction: column; align-items: center; - justify-content: flex-end; text-align: center; - min-height: 74px; .number { text-align: center; font-size: 48px; diff --git a/webapp/src/ts/modules/analytics/analytics-targets.component.html b/webapp/src/ts/modules/analytics/analytics-targets.component.html index 31cd186533b..2f72edf6c53 100644 --- a/webapp/src/ts/modules/analytics/analytics-targets.component.html +++ b/webapp/src/ts/modules/analytics/analytics-targets.component.html @@ -36,10 +36,10 @@ [aggregate]="false">
+
{{ getDisplayCount(target) | localizeNumber }}

{{ 'analytics.target.monthly_goal' | translate }} {{ target.goal | localizeNumber }}

-
{{ getDisplayCount(target) | localizeNumber }}