Skip to content

Commit ca2c985

Browse files
committed
add warning icon when custom and condition weights don't add up, refactor helper methods to make them shareable between components and consistent
1 parent ce820a9 commit ca2c985

File tree

21 files changed

+776
-103
lines changed

21 files changed

+776
-103
lines changed

frontend/projects/upgrade/src/app/core/experiments/condition-helper.service.spec.ts

Lines changed: 468 additions & 0 deletions
Large diffs are not rendered by default.

frontend/projects/upgrade/src/app/core/experiments/condition-helper.service.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,83 @@ import {
77
ExperimentCondition,
88
UpdateExperimentConditionsRequest,
99
ConditionFormData,
10+
WeightingMethod,
11+
WEIGHTING_METHOD,
1012
} from './store/experiments.model';
13+
import { ConditionWeightUpdate } from '../../features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component';
14+
15+
export const WEIGHT_CONFIG = {
16+
TOTAL_WEIGHT: 100,
17+
DECIMAL_PLACES: 2,
18+
VALIDATION_TOLERANCE: 0.1, // Allow rounding errors from equal distribution (e.g., 3 × 33.33 = 99.99)
19+
} as const;
20+
21+
// ============================================================================
22+
// Pure Functions (exported for use in selectors and other pure contexts)
23+
// ============================================================================
24+
25+
/**
26+
* Round to configured decimal places (2 decimals)
27+
*/
28+
function roundToDecimalPlaces(value: number): number {
29+
const multiplier = Math.pow(10, WEIGHT_CONFIG.DECIMAL_PLACES);
30+
return Math.round(value * multiplier) / multiplier;
31+
}
32+
33+
/**
34+
* Check if two weights are equal within tolerance
35+
*/
36+
function isWeightEqual(actual: number, expected: number): boolean {
37+
return Math.abs(actual - expected) < WEIGHT_CONFIG.VALIDATION_TOLERANCE;
38+
}
39+
40+
/**
41+
* Determine the weighting method for a set of conditions.
42+
* Returns 'equal' if all weights are the same, 'custom' otherwise.
43+
*/
44+
export function determineWeightingMethod(conditions: ExperimentCondition[] | null | undefined): WeightingMethod {
45+
if (!conditions || conditions.length === 0) {
46+
return WEIGHTING_METHOD.EQUAL; // Default to equal weighting for new experiments
47+
}
48+
49+
const expectedWeight = roundToDecimalPlaces(WEIGHT_CONFIG.TOTAL_WEIGHT / conditions.length);
50+
const isEqual = conditions.every((condition) => isWeightEqual(condition.assignmentWeight, expectedWeight));
51+
52+
return isEqual ? WEIGHTING_METHOD.EQUAL : WEIGHTING_METHOD.CUSTOM;
53+
}
54+
55+
/**
56+
* Distributes weights equally across all conditions.
57+
* All conditions get the exact same weight rounded to 2 decimals.
58+
* Total may be 99.99% or 100.01% due to rounding - this is acceptable.
59+
*/
60+
export function distributeWeightsEqually(conditions: ExperimentCondition[] | ConditionWeightUpdate[]): void {
61+
if (!conditions || conditions.length === 0) return;
62+
63+
const equalWeight = roundToDecimalPlaces(WEIGHT_CONFIG.TOTAL_WEIGHT / conditions.length);
64+
65+
conditions.forEach((condition) => {
66+
condition.assignmentWeight = equalWeight;
67+
});
68+
}
69+
70+
/**
71+
* Check if total weights sum to 100 within tolerance.
72+
* Used for validation warnings.
73+
*/
74+
export function isWeightSumValid(conditions: ExperimentCondition[] | null | undefined): boolean {
75+
if (!conditions || conditions.length === 0) {
76+
return true;
77+
}
78+
79+
const totalWeight = conditions.reduce((sum, condition) => sum + (condition.assignmentWeight || 0), 0);
80+
81+
return Math.abs(totalWeight - WEIGHT_CONFIG.TOTAL_WEIGHT) < WEIGHT_CONFIG.VALIDATION_TOLERANCE;
82+
}
83+
84+
// ============================================================================
85+
// Service Class (for stateful operations that need dependency injection)
86+
// ============================================================================
1187

1288
@Injectable({
1389
providedIn: 'root',
@@ -30,6 +106,12 @@ export class ConditionHelperService {
30106

31107
const updatedConditions = [...currentConditions, newCondition] as ExperimentCondition[];
32108

109+
// Check if weights should be distributed equally
110+
const weightingMethod = determineWeightingMethod(currentConditions);
111+
if (weightingMethod === WEIGHTING_METHOD.EQUAL) {
112+
distributeWeightsEqually(updatedConditions);
113+
}
114+
33115
this.updateExperimentConditions(experiment, updatedConditions);
34116
}
35117

@@ -71,6 +153,14 @@ export class ConditionHelperService {
71153
...c,
72154
order: index + 1,
73155
}));
156+
157+
// Check if weights should be redistributed equally
158+
const weightingMethod = determineWeightingMethod(currentConditions);
159+
160+
if (weightingMethod === WEIGHTING_METHOD.EQUAL && reorderedConditions.length > 0) {
161+
distributeWeightsEqually(reorderedConditions);
162+
}
163+
74164
this.updateExperimentConditions(updatedExperiment, reorderedConditions);
75165
}
76166

frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,8 +304,16 @@ export interface IExperimentGraphInfo {
304304

305305
export interface ExperimentVM extends Experiment {
306306
stat: IExperimentEnrollmentDetailStats;
307+
weightingMethod?: WeightingMethod;
307308
}
308309

310+
export type WeightingMethod = 'equal' | 'custom';
311+
312+
export const WEIGHTING_METHOD = {
313+
EQUAL: 'equal' as WeightingMethod,
314+
CUSTOM: 'custom' as WeightingMethod,
315+
} as const;
316+
309317
export enum UPSERT_EXPERIMENT_ACTION {
310318
ADD = 'add',
311319
EDIT = 'edit',

frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createSelector, createFeatureSelector } from '@ngrx/store';
22
import { selectAll } from './experiments.reducer';
3-
import { EXPERIMENT_SEARCH_KEY, ExperimentState, Experiment } from './experiments.model';
3+
import { EXPERIMENT_SEARCH_KEY, ExperimentState, Experiment, ExperimentVM } from './experiments.model';
44
import { selectRouterState } from '../../core.state';
55
import { ParticipantListTableRow } from '../../feature-flags/store/feature-flags.model';
66
import {
@@ -10,6 +10,7 @@ import {
1010
CONSISTENCY_RULE_DISPLAY_MAP,
1111
ASSIGNMENT_UNIT_DISPLAY_MAP,
1212
} from 'upgrade_types';
13+
import { determineWeightingMethod, isWeightSumValid } from '../condition-helper.service';
1314

1415
export const selectExperimentState = createFeatureSelector<ExperimentState>('experiments');
1516

@@ -32,7 +33,7 @@ export const selectIsLoadingExperimentDetailStats = createSelector(
3233
export const selectSelectedExperiment = createSelector(
3334
selectRouterState,
3435
selectExperimentState,
35-
(routerState, experimentState) => {
36+
(routerState, experimentState): ExperimentVM => {
3637
// be very defensive here to make sure routerState is correct
3738
const experimentId = routerState?.state?.params?.experimentId;
3839
if (experimentId && experimentState?.entities) {
@@ -45,12 +46,19 @@ export const selectSelectedExperiment = createSelector(
4546

4647
// Merge with stats if available
4748
const stat = experimentState.stats?.[experimentId] || null;
48-
return { ...experiment, stat };
49+
50+
const weightingMethod = determineWeightingMethod(experiment.conditions || []);
51+
52+
return { ...experiment, stat, weightingMethod };
4953
}
5054
return undefined;
5155
}
5256
);
5357

58+
export const selectConditionWeightsValid = createSelector(selectSelectedExperiment, (experiment): boolean => {
59+
return isWeightSumValid(experiment?.conditions || []);
60+
});
61+
5462
export const selectExperimentById = createSelector(
5563
selectExperimentState,
5664
(state, { experimentId }) => state.entities[experimentId]

frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ describe('EditConditionWeightsModalComponent', () => {
245245
};
246246

247247
const { component } = await setupComponent(threeConditionsData);
248-
component.distributeWeightsEqually();
248+
component.distributeWeightsEquallyInFormControls();
249249

250250
const total = component.getCurrentTotal();
251251
expect(total).toBe(100);

frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.ts

Lines changed: 28 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ import { Observable, combineLatest, map, startWith } from 'rxjs';
2121
import { CommonModalComponent } from '../../../../../shared-standalone-component-lib/components';
2222
import { CommonFormHelpersService } from '../../../../../shared/services/common-form-helpers.service';
2323
import { CommonModalConfig } from '../../../../../shared-standalone-component-lib/components/common-modal/common-modal.types';
24+
import {
25+
ExperimentCondition,
26+
WeightingMethod,
27+
WEIGHTING_METHOD,
28+
} from '../../../../../core/experiments/store/experiments.model';
29+
import { distributeWeightsEqually, WEIGHT_CONFIG } from '../../../../../core/experiments/condition-helper.service';
2430

2531
export interface ConditionWeightUpdate {
2632
conditionId: string;
@@ -52,15 +58,15 @@ export class EditConditionWeightsModalComponent implements OnInit {
5258
conditions: ConditionWeightUpdate[] = [];
5359
weightingMethods = [
5460
{
55-
value: 'equal',
61+
value: WEIGHTING_METHOD.EQUAL,
5662
name: this.translate.instant('experiments.edit-condition-weights-modal.equal-assignment-weights.label.text'),
5763
description: this.translate.instant(
5864
'experiments.edit-condition-weights-modal.equal-assignment-weights.description.text'
5965
),
6066
disabled: false,
6167
},
6268
{
63-
value: 'custom',
69+
value: WEIGHTING_METHOD.CUSTOM,
6470
name: this.translate.instant('experiments.edit-condition-weights-modal.custom-percentages.label.text'),
6571
description: this.translate.instant(
6672
'experiments.edit-condition-weights-modal.custom-percentages.description.text'
@@ -71,7 +77,10 @@ export class EditConditionWeightsModalComponent implements OnInit {
7177

7278
constructor(
7379
@Inject(MAT_DIALOG_DATA)
74-
public config: CommonModalConfig<{ experimentWeightsArray: ConditionWeightUpdate[] }>,
80+
public config: CommonModalConfig<{
81+
experimentWeightsArray: ConditionWeightUpdate[];
82+
weightingMethod: WeightingMethod;
83+
}>,
7584
public dialog: MatDialog,
7685
private readonly formBuilder: FormBuilder,
7786
private translate: TranslateService,
@@ -83,28 +92,25 @@ export class EditConditionWeightsModalComponent implements OnInit {
8392
}
8493

8594
createconditionWeightForm(): void {
86-
const { experimentWeightsArray } = this.config.params;
95+
const { experimentWeightsArray, weightingMethod } = this.config.params;
8796
this.conditions = experimentWeightsArray;
8897

89-
// Determine initial weighting method based on existing weights
90-
const initialWeightingMethod = this.determineInitialWeightingMethod(experimentWeightsArray);
91-
9298
// Create FormArray for conditions with individual validators
9399
const conditionsFormArray = this.formBuilder.array(
94100
experimentWeightsArray.map((condition) =>
95101
this.formBuilder.group({
96102
conditionCode: [condition.conditionCode],
97103
assignmentWeight: [
98104
condition.assignmentWeight,
99-
[Validators.required, Validators.min(0), Validators.max(100), this.decimalValidator],
105+
[Validators.required, Validators.min(0), Validators.max(WEIGHT_CONFIG.TOTAL_WEIGHT), this.decimalValidator],
100106
],
101107
})
102108
),
103109
[this.totalWeightValidator] // Array-level validator for sum = 100
104110
);
105111

106112
this.conditionWeightForm = this.formBuilder.group({
107-
weightingMethod: [initialWeightingMethod, Validators.required],
113+
weightingMethod: [weightingMethod, Validators.required],
108114
conditions: conditionsFormArray,
109115
});
110116

@@ -115,38 +121,13 @@ export class EditConditionWeightsModalComponent implements OnInit {
115121
this.watchWeightingMethodChanges();
116122

117123
// Set initial input state based on the determined method
118-
if (initialWeightingMethod === 'equal') {
124+
if (weightingMethod === WEIGHTING_METHOD.EQUAL) {
119125
this.disableWeightInputs();
120126
} else {
121127
this.enableWeightInputs();
122128
}
123129
}
124130

125-
private determineInitialWeightingMethod(conditions: ConditionWeightUpdate[]): string {
126-
if (!conditions || conditions.length === 0) {
127-
return 'equal';
128-
}
129-
130-
if (conditions.length === 1) {
131-
// Single condition should always be 100%
132-
return Math.abs(conditions[0].assignmentWeight - 100) < 0.01 ? 'equal' : 'custom';
133-
}
134-
135-
const expectedEqualWeight = 100 / conditions.length;
136-
137-
// Check if all weights are close to the expected equal distribution
138-
const areWeightsEquallyDistributed = conditions.every(
139-
(condition) => Math.abs(condition.assignmentWeight - expectedEqualWeight) < 0.01
140-
);
141-
142-
// Additional check: ensure total is close to 100%
143-
const totalWeight = conditions.reduce((sum, condition) => sum + condition.assignmentWeight, 0);
144-
const isTotalValid = Math.abs(totalWeight - 100) < 0.01;
145-
146-
// Return 'equal' only if weights are equally distributed AND total is valid
147-
return areWeightsEquallyDistributed && isTotalValid ? 'equal' : 'custom';
148-
}
149-
150131
private setupFormValidation(): void {
151132
this.isPrimaryButtonDisabled$ = combineLatest([
152133
this.conditionWeightForm.statusChanges.pipe(startWith(this.conditionWeightForm.status)),
@@ -166,10 +147,10 @@ export class EditConditionWeightsModalComponent implements OnInit {
166147
this.conditionWeightForm.get('weightingMethod')?.valueChanges.subscribe((method) => {
167148
this.conditionWeightForm.markAsDirty();
168149

169-
if (method === 'equal') {
170-
this.distributeWeightsEqually();
150+
if (method === WEIGHTING_METHOD.EQUAL) {
151+
this.distributeWeightsEquallyInFormControls();
171152
this.disableWeightInputs();
172-
} else if (method === 'custom') {
153+
} else if (method === WEIGHTING_METHOD.CUSTOM) {
173154
this.enableWeightInputs();
174155
} else if (method === null) {
175156
this.disableWeightInputs();
@@ -188,9 +169,9 @@ export class EditConditionWeightsModalComponent implements OnInit {
188169
return { invalidNumber: true };
189170
}
190171

191-
// Allow up to 2 decimal places
172+
// Allow up to configured decimal places
192173
const decimalPlaces = (control.value.toString().split('.')[1] || '').length;
193-
if (decimalPlaces > 2) {
174+
if (decimalPlaces > WEIGHT_CONFIG.DECIMAL_PLACES) {
194175
return { tooManyDecimals: true };
195176
}
196177

@@ -210,13 +191,13 @@ export class EditConditionWeightsModalComponent implements OnInit {
210191
},
211192
[0, {}] as [number, ValidationErrors]
212193
);
213-
const isValid = Math.abs(total - 100) < 0.01;
194+
const isValid = Math.abs(total - WEIGHT_CONFIG.TOTAL_WEIGHT) < WEIGHT_CONFIG.VALIDATION_TOLERANCE;
214195
const totalValidation = isValid
215196
? {}
216197
: {
217198
totalWeightInvalid: {
218199
actualTotal: Math.round(total * 100) / 100,
219-
expectedTotal: 100,
200+
expectedTotal: WEIGHT_CONFIG.TOTAL_WEIGHT,
220201
},
221202
};
222203
const allErrors = {
@@ -241,18 +222,13 @@ export class EditConditionWeightsModalComponent implements OnInit {
241222
}, 0);
242223
}
243224

244-
distributeWeightsEqually(): void {
245-
const equalWeight = Math.round((100 / this.conditions.length) * 100) / 100;
246-
let remainingWeight = 100;
225+
distributeWeightsEquallyInFormControls(): void {
226+
// Use service to calculate equal weights
227+
distributeWeightsEqually(this.conditions);
247228

229+
// Apply to form controls
248230
this.conditionsFormArray.controls.forEach((control, index) => {
249-
if (index === this.conditionsFormArray.controls.length - 1) {
250-
// Last condition gets the remaining weight to ensure total = 100
251-
control.get('assignmentWeight')?.setValue(Math.round(remainingWeight * 100) / 100);
252-
} else {
253-
control.get('assignmentWeight')?.setValue(equalWeight);
254-
remainingWeight -= equalWeight;
255-
}
231+
control.get('assignmentWeight')?.setValue(this.conditions[index].assignmentWeight);
256232
});
257233
}
258234

frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
[title]="'experiments.details.conditions.card.title.text' | translate"
66
[subtitle]="'experiments.details.conditions.card.subtitle.text' | translate"
77
[tableRowCount]="experiment.conditions?.length || 0"
8+
[warningMessage]="weightWarningText$ | async"
89
></app-common-section-card-title-header>
910

1011
<!-- header-right -->

0 commit comments

Comments
 (0)