From 9c1dbb5a3adf5525ca4ab9d14e407f7a5a18f5c8 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 20 Aug 2026 11:03:34 -0400 Subject: [PATCH 1/5] refactor(experiments): the reusable report pieces move out of old/ The Results screen needs the Chart.js options, the interactive legend plugin, the p-chart wrapper, the headless details table and the winner/promoted/chart-data helpers. All of them live under old/, which is frozen and slated for deletion, so they are relocated to shared/ and old/ is repointed at the new paths rather than duplicating them: one source of truth, and old/ stays deletable. Only import lines change under old/, so its behaviour is untouched and its specs still pass unmodified. Three importers the plan had not accounted for turned up in the Configure legacy screen and needed the same repoint. getConversionRate, getConversionRateRage, getBayesianVariantResult and getProbabilityToBeBest stay where they are: they are not needed outside old/, and the dependency direction has to remain old/ -> shared/, never the reverse. --- ...ents-configuration-goals.component.spec.ts | 2 +- ...periments-configuration-goals.component.ts | 2 +- ...nfiguration-variants-add.component.spec.ts | 2 +- ...nts-report-daily-details.component.spec.ts | 2 +- ...eriments-report-daily-details.component.ts | 2 +- .../dot-experiments-reports.component.spec.ts | 2 +- .../dot-experiments-reports.component.ts | 2 +- .../store/dot-experiments-reports-store.ts | 10 +- .../lib/old/shared/dot-experiment.utils.ts | 182 ------------- .../shared/dot-experiment-results.utils.ts | 242 ++++++++++++++++++ ...t-experiments-details-table.component.html | 0 ...xperiments-details-table.component.spec.ts | 0 ...dot-experiments-details-table.component.ts | 0 .../options/dotExperiments-chartjs.options.ts | 0 .../plugins/dotHtmlLegend-chartjs.plugin.ts | 0 ...t-experiments-reports-chart.component.html | 0 ...xperiments-reports-chart.component.spec.ts | 2 +- ...dot-experiments-reports-chart.component.ts | 2 +- 18 files changed, 257 insertions(+), 195 deletions(-) create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/dot-experiment-results.utils.ts rename core-web/libs/portlets/dot-experiments/portlet/src/lib/{old => }/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.html (100%) rename core-web/libs/portlets/dot-experiments/portlet/src/lib/{old => }/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.spec.ts (100%) rename core-web/libs/portlets/dot-experiments/portlet/src/lib/{old => }/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.ts (100%) rename core-web/libs/portlets/dot-experiments/portlet/src/lib/{old/dot-experiments-reports/components => shared/ui}/dot-experiments-reports-chart/chartjs/options/dotExperiments-chartjs.options.ts (100%) rename core-web/libs/portlets/dot-experiments/portlet/src/lib/{old/dot-experiments-reports/components => shared/ui}/dot-experiments-reports-chart/chartjs/plugins/dotHtmlLegend-chartjs.plugin.ts (100%) rename core-web/libs/portlets/dot-experiments/portlet/src/lib/{old/dot-experiments-reports/components => shared/ui}/dot-experiments-reports-chart/dot-experiments-reports-chart.component.html (100%) rename core-web/libs/portlets/dot-experiments/portlet/src/lib/{old/dot-experiments-reports/components => shared/ui}/dot-experiments-reports-chart/dot-experiments-reports-chart.component.spec.ts (97%) rename core-web/libs/portlets/dot-experiments/portlet/src/lib/{old/dot-experiments-reports/components => shared/ui}/dot-experiments-reports-chart/dot-experiments-reports-chart.component.ts (99%) diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.spec.ts index ee414272efef..c29debc601ef 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.spec.ts @@ -34,7 +34,7 @@ import { import { DotExperimentsConfigurationGoalsComponent } from './dot-experiments-configuration-goals.component'; -import { DotExperimentsDetailsTableComponent } from '../../../shared/ui/dot-experiments-details-table/dot-experiments-details-table.component'; +import { DotExperimentsDetailsTableComponent } from '../../../../shared/ui/dot-experiments-details-table/dot-experiments-details-table.component'; import { DotExperimentsConfigurationStore } from '../../store/dot-experiments-configuration-store'; import { DotExperimentsConfigurationGoalSelectComponent } from '../dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component'; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.ts index f2a790b6d447..8bc490bd4362 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.ts @@ -22,7 +22,7 @@ import { } from '@dotcms/dotcms-models'; import { DotDynamicDirective, DotMessagePipe } from '@dotcms/ui'; -import { DotExperimentsDetailsTableComponent } from '../../../shared/ui/dot-experiments-details-table/dot-experiments-details-table.component'; +import { DotExperimentsDetailsTableComponent } from '../../../../shared/ui/dot-experiments-details-table/dot-experiments-details-table.component'; import { DotExperimentsConfigurationStore } from '../../store/dot-experiments-configuration-store'; import { DotExperimentsConfigurationGoalSelectComponent } from '../dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component'; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.spec.ts index 232bfbbac66d..9b2fd12705b2 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.spec.ts @@ -22,7 +22,7 @@ import { ACTIVE_ROUTE_MOCK_CONFIG } from '@dotcms/utils-testing'; import { DotExperimentsConfigurationVariantsAddComponent } from './dot-experiments-configuration-variants-add.component'; -import { DotExperimentsReportsChartComponent } from '../../../dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component'; +import { DotExperimentsReportsChartComponent } from '../../../../shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component'; import { DotExperimentsConfigurationStore } from '../../store/dot-experiments-configuration-store'; describe('DotExperimentsConfigurationVariantsAddComponent', () => { diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.spec.ts index 3e385250749e..054d99d77588 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.spec.ts @@ -8,7 +8,7 @@ import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotExperimentsReportDailyDetailsComponent } from './dot-experiments-report-daily-details.component'; -import { DotExperimentsDetailsTableComponent } from '../../../shared/ui/dot-experiments-details-table/dot-experiments-details-table.component'; +import { DotExperimentsDetailsTableComponent } from '../../../../shared/ui/dot-experiments-details-table/dot-experiments-details-table.component'; import { DotExperimentsReportsStore } from '../../store/dot-experiments-reports-store'; const messageServiceMock = new MockDotMessageService({ diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.ts index e747b074380e..d0cc9b859056 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.ts @@ -10,7 +10,7 @@ import { DotMessageService } from '@dotcms/data-access'; import { DEFAULT_VARIANT_ID, DotExperimentVariantDetail, Variant } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; -import { DotExperimentsDetailsTableComponent } from '../../../shared/ui/dot-experiments-details-table/dot-experiments-details-table.component'; +import { DotExperimentsDetailsTableComponent } from '../../../../shared/ui/dot-experiments-details-table/dot-experiments-details-table.component'; import { DotExperimentsReportsStore } from '../../store/dot-experiments-reports-store'; @Component({ diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.spec.ts index a12fefcc2e1a..3b8847d5c425 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.spec.ts @@ -28,7 +28,6 @@ import { import { DotExperimentsExperimentSummaryComponent } from './components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component'; import { DotExperimentsReportDailyDetailsComponent } from './components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component'; -import { DotExperimentsReportsChartComponent } from './components/dot-experiments-reports-chart/dot-experiments-reports-chart.component'; import { DotExperimentsReportsSkeletonComponent } from './components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component'; import { DotExperimentsReportsComponent } from './dot-experiments-reports.component'; import { @@ -36,6 +35,7 @@ import { VmReportExperiment } from './store/dot-experiments-reports-store'; +import { DotExperimentsReportsChartComponent } from '../../shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component'; import { DotExperimentsUiHeaderComponent } from '../shared/ui/dot-experiments-header/dot-experiments-ui-header.component'; const ActivatedRouteMock = { diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.ts index 9fb289376553..1fd2b60f3a0a 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.ts @@ -16,13 +16,13 @@ import { DotDynamicDirective, DotMessagePipe } from '@dotcms/ui'; import { DotExperimentsExperimentSummaryComponent } from './components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component'; import { DotExperimentsReportDailyDetailsComponent } from './components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component'; -import { DotExperimentsReportsChartComponent } from './components/dot-experiments-reports-chart/dot-experiments-reports-chart.component'; import { DotExperimentsReportsSkeletonComponent } from './components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component'; import { DotExperimentsReportsStore, VmReportExperiment } from './store/dot-experiments-reports-store'; +import { DotExperimentsReportsChartComponent } from '../../shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component'; import { DotExperimentsUiHeaderComponent } from '../shared/ui/dot-experiments-header/dot-experiments-ui-header.component'; @Component({ diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/store/dot-experiments-reports-store.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/store/dot-experiments-reports-store.ts index 30bf705ee40c..a031ab223bca 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/store/dot-experiments-reports-store.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/store/dot-experiments-reports-store.ts @@ -36,16 +36,18 @@ import { import { getBayesianDatasets, - getBayesianVariantResult, - getConversionRate, - getConversionRateRage, getParsedChartData, getPreviousDay, - getProbabilityToBeBest, getPropertyColors, getSuggestedWinner, isPromotedVariant, orderVariants +} from '../../../shared/dot-experiment-results.utils'; +import { + getBayesianVariantResult, + getConversionRate, + getConversionRateRage, + getProbabilityToBeBest } from '../../shared/dot-experiment.utils'; export interface DotExperimentsReportsState { diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/dot-experiment.utils.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/dot-experiment.utils.ts index 0bca7401ba81..6b51499140a6 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/dot-experiment.utils.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/dot-experiment.utils.ts @@ -1,56 +1,17 @@ -import { ChartData } from 'chart.js'; -import { jStat } from 'jstat'; - import { formatPercent } from '@angular/common'; import { - BayesianStatusResponse, ComponentStatus, - DEFAULT_VARIANT_ID, DotBayesianVariantResult, DotCreditabilityInterval, - DotExperiment, - DotExperimentResults, - DotExperimentStatus, - DotResultDate, - ExperimentChartDatasetColorsVariants, - ExperimentLinearChartDatasetDefaultProperties, ExperimentSteps, - LineChartColorsProperties, PROP_NOT_FOUND, - ReportSummaryLegendByBayesianStatus, - SummaryLegend, TIME_7_DAYS, TIME_90_DAYS } from '@dotcms/dotcms-models'; const ONE_DAY = 24 * 60 * 60 * 1000; -export const orderVariants = (arrayToOrder: Array): Array => { - const index = arrayToOrder.indexOf(DEFAULT_VARIANT_ID); - if (index > -1) { - arrayToOrder.splice(index, 1); - } - - arrayToOrder.unshift(DEFAULT_VARIANT_ID); - - return arrayToOrder; -}; - -/** - * Retrieves an array of uniqueBySession values from the given data. - * - * @param {Record} data - The data object containing DotResultDate values. - * @return {number[]} - An array of conversion Rate values. - */ -export const getParsedChartData = (data: Record): number[] => { - return [0, ...Object.values(data).map((day) => Math.round(day.conversionRate * 100) / 100)]; -}; - -export const getPropertyColors = (index: number): LineChartColorsProperties => { - return ExperimentChartDatasetColorsVariants[index]; -}; - /** * Process the config properties that comes form the BE as days, * return the object with the values in milliseconds @@ -118,149 +79,6 @@ export const getProbabilityToBeBest = (probability: number, noDataLabel: string) return probability ? getPercentageFormat(probability) : noDataLabel; }; -export const isPromotedVariant = (experiment: DotExperiment, variantName: string): boolean => { - return !!experiment.trafficProportion.variants.find(({ id }) => id === variantName)?.promoted; -}; - -export const getPreviousDay = (givenDate: string) => { - const [year, month, day] = givenDate.split('-').map(Number); - - // Create a Date object in UTC | - 1 - Months are zero-based in JavaScript - const inputDateUTC = new Date(Date.UTC(year, month - 1, day)); - - // in milliseconds to avoid TIMEZONE issues & month change. - inputDateUTC.setTime(inputDateUTC.getTime() - ONE_DAY); - - // Format the date as "YYYY-MM-dd" - return inputDateUTC.toISOString().split('T')[0]; -}; - -export const getRandomUUID = () => self.crypto.randomUUID(); - -export const getSuggestedWinner = ( - experiment: DotExperiment, - results: DotExperimentResults -): SummaryLegend => { - const { bayesianResult, sessions } = results; - - if (!bayesianResult) { - return ReportSummaryLegendByBayesianStatus.NO_ENOUGH_SESSIONS; - } - - const hasSessions = sessions.total > 0; - const isATieBayesianSuggestionWinner = - bayesianResult?.suggestedWinner === BayesianStatusResponse.TIE; - const isNoneBayesianSuggestionWinner = - bayesianResult?.suggestedWinner === BayesianStatusResponse.NONE; - - if (!hasSessions || isNoneBayesianSuggestionWinner) { - return experiment.status === DotExperimentStatus.ENDED - ? ReportSummaryLegendByBayesianStatus.NO_WINNER_FOUND - : ReportSummaryLegendByBayesianStatus.NO_ENOUGH_SESSIONS; - } - - if (isATieBayesianSuggestionWinner) { - return { ...ReportSummaryLegendByBayesianStatus.NO_WINNER_FOUND }; - } - - return experiment.status === DotExperimentStatus.ENDED - ? { ...ReportSummaryLegendByBayesianStatus.WINNER } - : { ...ReportSummaryLegendByBayesianStatus.PRELIMINARY_WINNER }; -}; - -/** - * Generate the data to use in the Bayesian chart - * @param results - */ -export const getBayesianDatasets = ( - results: DotExperimentResults -): ChartData<'line'>['datasets'] => { - const { variants } = results.goals.primary; - const { sessions, bayesianResult } = results; - - // If we don't have a suggested winner, return an empty array - if (!bayesianResult || bayesianResult.suggestedWinner === BayesianStatusResponse.NONE) { - return []; - } - - // Iterate through all the variants - return Object.entries(variants).map(([variantId, variant], index) => { - // Calculate the number of successes and failures - const success = variant.uniqueBySession.count; - const failure = sessions.variants[variantId] - variant.uniqueBySession.count; - const label = variant.variantDescription; - - // Generate the data for the chart, I need at least 1 failure to generate data - const data: { x: number; y: number }[] = - failure > 0 ? generateProbabilityDensityData(success, failure) : []; - - // Create the dataset - return { - label, - data, - ...getPropertyColors(index), - ...ExperimentLinearChartDatasetDefaultProperties - }; - }); -}; - -/** - * Generates the data for the probability density function of a beta distribution. - * @param {number} alpha - The alpha parameter of the beta distribution. - * @param {number} beta - The beta parameter of the beta distribution. - * @param {number} step - * @returns {object[]} An array of objects with x and y values. - */ -const generateProbabilityDensityData = ( - alpha: number, - beta: number, - step = 0.01 -): { x: number; y: number }[] => { - // Create a beta distribution object using the alpha and beta parameters. - const betaDist = new jStat.beta(alpha, beta); - - const data = []; - // Loop through the x values from 0 to 1. - for (let i = 0; i <= 1; i += step) { - // Set the x value to the current value of i. - const x = Number(i.toFixed(2)); - // Set the y value to the value of the pdf at the current value of i. - const y = Number(betaDist.pdf(x).toFixed(2)); - - if (!isFinite(y)) { - continue; - } - - // Add the x and y values to the data array. - data.push({ x, y }); - } - - return arePointsALine(data) ? [] : data; -}; - -/** - * Check if a set of points are all on the same line. - * - * @param {Array<{ x: number; y: number }>} points - The array of points to check. - * @returns {boolean} - True if all points are on the same line, false otherwise. - */ -const arePointsALine = (points: { x: number; y: number }[]): boolean => { - if (points.length < 3) { - return true; - } - - const referenceSlope = (points[1].y - points[0].y) / (points[1].x - points[0].x); - - for (let i = 1; i < points.length - 1; i++) { - const slope = (points[i + 1].y - points[i].y) / (points[i + 1].x - points[i].x); - if (Math.abs(slope - referenceSlope) > 1e-6) { - return false; - } - } - - return true; -}; - /** * Given a number, identify if is lower that 10% round 2 decimals if is higher than 10 round to 1 decimal */ diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/dot-experiment-results.utils.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/dot-experiment-results.utils.ts new file mode 100644 index 000000000000..245bbcda9a6a --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/dot-experiment-results.utils.ts @@ -0,0 +1,242 @@ +import { ChartData } from 'chart.js'; +import { jStat } from 'jstat'; + +import { DotMessageService } from '@dotcms/data-access'; +import { + BayesianStatusResponse, + DEFAULT_VARIANT_ID, + DotExperiment, + DotExperimentResults, + DotExperimentStatus, + DotResultDate, + DotResultGoal, + ExperimentChartDatasetColorsVariants, + ExperimentLinearChartDatasetDefaultProperties, + ExperimentLineChartDatasetDefaultProperties, + LineChartColorsProperties, + MonthsOfTheYear, + ReportSummaryLegendByBayesianStatus, + SummaryLegend +} from '@dotcms/dotcms-models'; + +const ONE_DAY = 24 * 60 * 60 * 1000; + +export const orderVariants = (arrayToOrder: Array): Array => { + const index = arrayToOrder.indexOf(DEFAULT_VARIANT_ID); + if (index > -1) { + arrayToOrder.splice(index, 1); + } + + arrayToOrder.unshift(DEFAULT_VARIANT_ID); + + return arrayToOrder; +}; + +/** + * Retrieves an array of uniqueBySession values from the given data. + * + * @param {Record} data - The data object containing DotResultDate values. + * @return {number[]} - An array of conversion Rate values. + */ +export const getParsedChartData = (data: Record): number[] => { + return [0, ...Object.values(data).map((day) => Math.round(day.conversionRate * 100) / 100)]; +}; + +export const getPropertyColors = (index: number): LineChartColorsProperties => { + return ExperimentChartDatasetColorsVariants[index]; +}; + +export const isPromotedVariant = (experiment: DotExperiment, variantName: string): boolean => { + return !!experiment.trafficProportion.variants.find(({ id }) => id === variantName)?.promoted; +}; + +export const getPreviousDay = (givenDate: string) => { + const [year, month, day] = givenDate.split('-').map(Number); + + // Create a Date object in UTC | - 1 - Months are zero-based in JavaScript + const inputDateUTC = new Date(Date.UTC(year, month - 1, day)); + + // in milliseconds to avoid TIMEZONE issues & month change. + inputDateUTC.setTime(inputDateUTC.getTime() - ONE_DAY); + + // Format the date as "YYYY-MM-dd" + return inputDateUTC.toISOString().split('T')[0]; +}; + +export const getRandomUUID = () => self.crypto.randomUUID(); + +export const getSuggestedWinner = ( + experiment: DotExperiment, + results: DotExperimentResults +): SummaryLegend => { + const { bayesianResult, sessions } = results; + + if (!bayesianResult) { + return ReportSummaryLegendByBayesianStatus.NO_ENOUGH_SESSIONS; + } + + const hasSessions = sessions.total > 0; + const isATieBayesianSuggestionWinner = + bayesianResult?.suggestedWinner === BayesianStatusResponse.TIE; + const isNoneBayesianSuggestionWinner = + bayesianResult?.suggestedWinner === BayesianStatusResponse.NONE; + + if (!hasSessions || isNoneBayesianSuggestionWinner) { + return experiment.status === DotExperimentStatus.ENDED + ? ReportSummaryLegendByBayesianStatus.NO_WINNER_FOUND + : ReportSummaryLegendByBayesianStatus.NO_ENOUGH_SESSIONS; + } + + if (isATieBayesianSuggestionWinner) { + return { ...ReportSummaryLegendByBayesianStatus.NO_WINNER_FOUND }; + } + + return experiment.status === DotExperimentStatus.ENDED + ? { ...ReportSummaryLegendByBayesianStatus.WINNER } + : { ...ReportSummaryLegendByBayesianStatus.PRELIMINARY_WINNER }; +}; + +/** + * Generate the data to use in the Bayesian chart + * @param results + */ +export const getBayesianDatasets = ( + results: DotExperimentResults +): ChartData<'line'>['datasets'] => { + const { variants } = results.goals.primary; + const { sessions, bayesianResult } = results; + + // If we don't have a suggested winner, return an empty array + if (!bayesianResult || bayesianResult.suggestedWinner === BayesianStatusResponse.NONE) { + return []; + } + + // Iterate through all the variants + return Object.entries(variants).map(([variantId, variant], index) => { + // Calculate the number of successes and failures + const success = variant.uniqueBySession.count; + const failure = sessions.variants[variantId] - variant.uniqueBySession.count; + const label = variant.variantDescription; + + // Generate the data for the chart, I need at least 1 failure to generate data + const data: { x: number; y: number }[] = + failure > 0 ? generateProbabilityDensityData(success, failure) : []; + + // Create the dataset + return { + label, + data, + ...getPropertyColors(index), + ...ExperimentLinearChartDatasetDefaultProperties + }; + }); +}; + +/** + * Builds one line dataset per variant, control first, for the daily conversion rate chart. + * + * @param variants - Primary goal results keyed by variant id + * @returns Chart.js line datasets in display order + */ +export const buildDailyChartData = ( + variants: DotResultGoal['variants'] +): ChartData<'line'>['datasets'] => { + const variantsOrdered = orderVariants(Object.keys(variants)); + + let colorIndex = 0; + + return variantsOrdered.map((variantName) => { + const { details } = variants[variantName]; + + return { + label: variants[variantName].variantDescription, + data: getParsedChartData(details), + ...getPropertyColors(colorIndex++), + ...ExperimentLineChartDatasetDefaultProperties + }; + }); +}; + +/** + * Builds the translated `month-day` axis labels for the daily conversion rate chart. + * + * The label list is prefixed with the day before the first result so the chart starts at zero. + * + * @param variants - Primary goal results keyed by variant id + * @param dotMessageService - Used to translate the month name + * @returns Ordered axis labels, empty when the control variant has no daily details + */ +export const buildDailyChartLabels = ( + variants: DotResultGoal['variants'], + dotMessageService: DotMessageService +): string[] => { + return variants[DEFAULT_VARIANT_ID].details + ? parseDaysLabels(Object.keys(variants[DEFAULT_VARIANT_ID].details), dotMessageService) + : []; +}; + +/** + * Generates the data for the probability density function of a beta distribution. + * @param {number} alpha - The alpha parameter of the beta distribution. + * @param {number} beta - The beta parameter of the beta distribution. + * @param {number} step + * @returns {object[]} An array of objects with x and y values. + */ +const generateProbabilityDensityData = ( + alpha: number, + beta: number, + step = 0.01 +): { x: number; y: number }[] => { + // Create a beta distribution object using the alpha and beta parameters. + const betaDist = new jStat.beta(alpha, beta); + + const data = []; + // Loop through the x values from 0 to 1. + for (let i = 0; i <= 1; i += step) { + // Set the x value to the current value of i. + const x = Number(i.toFixed(2)); + // Set the y value to the value of the pdf at the current value of i. + const y = Number(betaDist.pdf(x).toFixed(2)); + + if (!isFinite(y)) { + continue; + } + + // Add the x and y values to the data array. + data.push({ x, y }); + } + + return arePointsALine(data) ? [] : data; +}; + +/** + * Check if a set of points are all on the same line. + * + * @param {Array<{ x: number; y: number }>} points - The array of points to check. + * @returns {boolean} - True if all points are on the same line, false otherwise. + */ +const arePointsALine = (points: { x: number; y: number }[]): boolean => { + if (points.length < 3) { + return true; + } + + const referenceSlope = (points[1].y - points[0].y) / (points[1].x - points[0].x); + + for (let i = 1; i < points.length - 1; i++) { + const slope = (points[i + 1].y - points[i].y) / (points[i + 1].x - points[i].x); + if (Math.abs(slope - referenceSlope) > 1e-6) { + return false; + } + } + + return true; +}; + +const parseDaysLabels = (labels: Array, dotMessageService: DotMessageService): string[] => { + return [getPreviousDay(labels[0]), ...labels].map((item) => { + const [, month, day] = item.split('-').map(Number); + const monthTranslated = dotMessageService.get(MonthsOfTheYear[month - 1]); + + return `${monthTranslated}-${day}`; + }); +}; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/chartjs/options/dotExperiments-chartjs.options.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-reports-chart/chartjs/options/dotExperiments-chartjs.options.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/chartjs/options/dotExperiments-chartjs.options.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-reports-chart/chartjs/options/dotExperiments-chartjs.options.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/chartjs/plugins/dotHtmlLegend-chartjs.plugin.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-reports-chart/chartjs/plugins/dotHtmlLegend-chartjs.plugin.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/chartjs/plugins/dotHtmlLegend-chartjs.plugin.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-reports-chart/chartjs/plugins/dotHtmlLegend-chartjs.plugin.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component.spec.ts similarity index 97% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component.spec.ts index 41234382ed06..81f0c01d9b73 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component.spec.ts @@ -13,7 +13,7 @@ import { import { DotExperimentsReportsChartComponent } from './dot-experiments-reports-chart.component'; -import * as Utilities from '../../../shared/dot-experiment.utils'; +import * as Utilities from '../../dot-experiment-results.utils'; const messageServiceMock = new MockDotMessageService({ 'experiments.reports.chart.empty.title': 'x axis label', diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component.ts similarity index 99% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component.ts index d0349ae1eb83..6b15da15fffd 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component.ts @@ -10,7 +10,7 @@ import { DotMessagePipe } from '@dotcms/ui'; import { generateDotExperimentLineChartJsOptions } from './chartjs/options/dotExperiments-chartjs.options'; import { htmlLegendPlugin } from './chartjs/plugins/dotHtmlLegend-chartjs.plugin'; -import { getRandomUUID } from '../../../shared/dot-experiment.utils'; +import { getRandomUUID } from '../../dot-experiment-results.utils'; @Component({ selector: 'dot-experiments-reports-chart', From 4d754c85c7ce1470be425080049fbb938c1c1f34 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 20 Aug 2026 11:03:50 -0400 Subject: [PATCH 2/5] feat(experiments): a results store on Signal Store Events State is {experiment, results, status, refreshing, lastRefreshFailed}, keyed by experimentId alone, so the screen is page-independent the way the old reports store already was. The load runs sequentially rather than as the old screen's forkJoin, because the second call depends on the first: DRAFT and SCHEDULED settle on getById alone and never reach getResults, which is uncached and costs two analytics round-trips plus a Monte Carlo run. The branch reads the experiment's status, not whether results happen to be null. A failed refresh leaves results and status untouched, so the last good report stays on screen and only a flag says the refresh failed. A first load that loses only its report keeps the experiment and reports inline; blanking there would regress against the screen this one replaces. Lift vs Original is computed here since no field carries it. The baseline is resolved by key and isControl by name, never by row position, so the order rows arrive in cannot change the arithmetic. The refresh flag is raised only for the statuses the handler will actually serve; raising it for the others would spin forever with no request in flight. --- .../src/lib/dot-experiments.model.ts | 8 + .../portlet/src/lib/shared/models.ts | 45 ++ .../dot-experiments-results-api.events.ts | 64 +++ .../dot-experiments-results-page.events.ts | 36 ++ .../dot-experiments-results.store.spec.ts | 431 +++++++++++++++++ .../store/dot-experiments-results.store.ts | 456 ++++++++++++++++++ .../lib/util/dot-experiments-results.util.ts | 151 ++++++ 7 files changed, 1191 insertions(+) create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results-api.events.ts create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results-page.events.ts create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results.store.spec.ts create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results.store.ts create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-results.util.ts diff --git a/core-web/libs/dotcms-models/src/lib/dot-experiments.model.ts b/core-web/libs/dotcms-models/src/lib/dot-experiments.model.ts index 8ebe31cc6772..51f7960adbf2 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-experiments.model.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-experiments.model.ts @@ -118,6 +118,14 @@ export interface DotExperimentVariantDetail { probabilityToBeBest: string; isWinner: boolean; isPromoted: boolean; + /** + * Difference against the control's conversion rate, ready to render: signed percentage points + * with one decimal, or an em dash on the control row and when the control converted nothing. + * + * Optional because the backend does not send it and the legacy reports screen does not compute + * it — only the results screen does. + */ + liftVsOriginal?: string; } export interface Variant { diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/models.ts index 8646bda0970e..885f277dc26d 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/models.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/models.ts @@ -4,7 +4,9 @@ import { ComponentStatus, DotExperiment, DotExperimentPatchBody, + DotExperimentResults, DotExperimentStatus, + DotExperimentVariantDetail, GOAL_OPERATORS, GOAL_TYPES } from '@dotcms/dotcms-models'; @@ -268,3 +270,46 @@ export interface VariantRowViewModel { /** i18n key explaining `disabled`; `null` when the row is editable. */ disabledTooltipKey: string | null; } + +/** How a Lift vs Original reads: a gain, a loss, or nothing to compare against (AC16). */ +export type LiftTone = 'neutral' | 'positive' | 'negative'; + +/** Translated copy the summary table needs for values the backend does not supply. */ +export interface VariantDetailLabels { + /** Shown where the backend has not computed a range or a probability yet. */ + noDataLabel: string; + /** Sits between the two bounds of the 95% conversion rate range. */ + rangeSeparatorLabel: string; +} + +/** + * A summary-table row: the shared variant detail plus the Lift vs Original. + * + * Lift has no backend field — it is the variant's conversion rate minus the control's — so it is + * additive here rather than in `DotExperimentVariantDetail`, which the old reports screen shares. + */ +export interface DotExperimentResultVariantDetail extends DotExperimentVariantDetail { + /** Signed percentage points to one decimal, or an em dash when there is nothing to compare. */ + liftVsOriginal: string; + liftTone: LiftTone; +} + +/** Everything the Results screen renders from. */ +export interface DotExperimentsResultsViewState { + /** `null` until the experiment has loaded, which is also what the skeleton reads (AC23). */ + experiment: DotExperiment | null; + /** + * `null` while the experiment is DRAFT or SCHEDULED: `getResults` is uncached and costs two + * analytics round-trips plus a Monte Carlo run, so it is never called before there is + * anything to count (AC10). + */ + results: DotExperimentResults | null; + status: ComponentStatus; + /** True while a manual refresh is in flight, so the control cannot fire a second one (AC9). */ + refreshing: boolean; + /** + * True when the last manual refresh was rejected. Kept apart from `status`: the results + * already on screen stay exactly as they are, and the screen says so without blanking (AC25). + */ + lastRefreshFailed: boolean; +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results-api.events.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results-api.events.ts new file mode 100644 index 000000000000..92f1d962050e --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results-api.events.ts @@ -0,0 +1,64 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; + +import { DotExperiment, DotExperimentResults } from '@dotcms/dotcms-models'; + +/** What the initial load answered with: the experiment always, its results only when it has any. */ +export interface ResultsLoadPayload { + experiment: DotExperiment; + /** `null` for a DRAFT or SCHEDULED experiment, whose results are never fetched (AC10). */ + results: DotExperimentResults | null; +} + +/** + * What the backend answered on the Results screen: every event here is dispatched from a store + * event handler once a request settles, never by a component. The matching intents belong to + * `dotExperimentsResultsPageEvents`. + * + * The initial load settles as one `load…` pair rather than a pair per call: the experiment decides + * whether its results are worth fetching at all, so the two travel as a single unit and the screen + * has either both or neither. + * + * Every `…Succeeded` of a mutation carries the experiment the server answered with: it is the + * source of truth after any write, and the shell needs its name for the toast copy. + */ +export const dotExperimentsResultsApiEvents = eventGroup({ + source: 'Experiments Results API', + events: { + loadSucceeded: type(), + /** + * The experiment itself could not be read, so there is nothing to frame a report with: + * this is the one failure that blanks the screen into a full error state with a retry + * (AC24). + */ + loadFailed: type(), + /** + * The experiment read fine but its report did not. Everything the experiment already + * answers for — name, status, goal, schedule — is on screen, so the screen keeps its shape + * and reports the missing report inline rather than replacing itself with an error card. + * + * This is the common case while experiment results still run through CubeJS: a schema + * without the `Events` cube answers `getResults` with a 400 while `getById` succeeds. The + * screen this one replaces degraded the same way, and blanking here would be a regression. + */ + resultsUnavailable: type(), + + // Manual refresh. Results only — the experiment cannot change under the screen. + refreshSucceeded: type(), + /** + * The results already on screen are the last good ones and stay exactly as they are: a + * refresh that fails is reported without blanking a screen that has already loaded (AC25). + */ + refreshFailed: type(), + + stopSucceeded: type(), + stopFailed: type(), + + /** + * Promoting a RUNNING experiment ends it server-side, so the experiment carried here + * already reads ENDED — the header re-renders in place off this one event (AC20). + */ + promoteSucceeded: type(), + promoteFailed: type() + } +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results-page.events.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results-page.events.ts new file mode 100644 index 000000000000..dc1159925b7c --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results-page.events.ts @@ -0,0 +1,36 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; + +/** + * What the Results page asks for: user intent and lifecycle, never a result. + * + * Every event here is dispatched by the screen itself — the shell coming up on a URL, the refresh + * control, a confirmed Stop or Promote. What comes *back* lives in + * `dotExperimentsResultsApiEvents`, so the two halves of an async flow are never confused. + * + * Both mutations are already confirmed by the time they are dispatched: the store never opens UI, + * so the confirm dialogs and the toasts that follow belong to the shell. + */ +export const dotExperimentsResultsPageEvents = eventGroup({ + source: 'Experiments Results Page', + events: { + /** The screen came up on `/experiments/:experimentId/results`, carrying that id. */ + enter: type(), + + /** + * The refresh control was pressed. Only the results are re-fetched — the experiment itself + * cannot change while the screen sits on it — and the ones on screen stay put until the + * new ones arrive (AC9). + */ + refreshRequested: type(), + + /** A confirmed Stop. Only reachable while the experiment is RUNNING (AC3). */ + stopRequested: type(), + + /** + * A confirmed Promote, carrying the variant id. Promoting a RUNNING experiment also ends + * it, which the backend does in the same call — so this is one event, not two (AC20). + */ + promoteRequested: type() + } +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results.store.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results.store.spec.ts new file mode 100644 index 000000000000..c47166da0ef3 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results.store.spec.ts @@ -0,0 +1,431 @@ +import { Dispatcher, provideDispatcher } from '@ngrx/signals/events'; +import { createServiceFactory, mockProvider, SpectatorService } from '@openng/spectator/jest'; +import { NEVER, of, throwError } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { ActivatedRoute, convertToParamMap, Params } from '@angular/router'; + +import { + DotExperimentsService, + DotHttpErrorManagerService, + DotMessageService +} from '@dotcms/data-access'; +import { + ComponentStatus, + DEFAULT_VARIANT_ID, + DotExperiment, + DotExperimentResults, + DotExperimentStatus, + DotResultVariant, + GOAL_TYPES, + TrafficProportionTypes, + Variant +} from '@dotcms/dotcms-models'; + +import { dotExperimentsResultsPageEvents } from './dot-experiments-results-page.events'; +import { DotExperimentsResultsStore } from './dot-experiments-results.store'; + +const pageEvents = dotExperimentsResultsPageEvents; + +const EXPERIMENT_ID = 'exp-1'; +const VARIANT_B_ID = 'variant-b'; + +/** The fallback title the store supplies when a rejected results call carries none of its own. */ +const RESULTS_ERROR_HEADER_KEY = + 'dot.common.http.error.400.experiment.analytics-app-not-configured.header'; + +const buildVariant = (id: string, promoted = false): Variant => ({ + id, + name: id, + weight: 50, + promoted +}); + +const buildExperiment = (experiment: Partial = {}): DotExperiment => ({ + id: EXPERIMENT_ID, + pageId: 'page-1', + name: 'Alpha campaign', + description: 'Checkout funnel rework', + status: DotExperimentStatus.RUNNING, + readyToStart: true, + archived: false, + trafficProportion: { + type: TrafficProportionTypes.SPLIT_EVENLY, + variants: [buildVariant(DEFAULT_VARIANT_ID), buildVariant(VARIANT_B_ID)] + }, + trafficAllocation: 100, + scheduling: null, + creationDate: new Date('2026-01-01T00:00:00.000Z'), + modDate: 0, + goals: null, + ...experiment +}); + +const RUNNING_EXPERIMENT = buildExperiment(); +const DRAFT_EXPERIMENT = buildExperiment({ status: DotExperimentStatus.DRAFT }); +const SCHEDULED_EXPERIMENT = buildExperiment({ status: DotExperimentStatus.SCHEDULED }); + +const buildResultVariant = (variantName: string, conversions: number): DotResultVariant => ({ + details: {}, + multiBySession: conversions, + uniqueBySession: { count: conversions, totalPercentage: 100, variantPercentage: 100 }, + variantName, + variantDescription: `${variantName} name`, + totalPageViews: 100 +}); + +const buildResults = (sessionsTotal = 40): DotExperimentResults => ({ + bayesianResult: { value: 0.9, suggestedWinner: VARIANT_B_ID, results: [] }, + goals: { + primary: { + goal: { name: 'Reach page', type: GOAL_TYPES.REACH_PAGE, conditions: [] }, + variants: { + [DEFAULT_VARIANT_ID]: buildResultVariant(DEFAULT_VARIANT_ID, 5), + [VARIANT_B_ID]: buildResultVariant(VARIANT_B_ID, 12) + } + } + }, + sessions: { + total: sessionsTotal, + variants: { [DEFAULT_VARIANT_ID]: sessionsTotal / 2, [VARIANT_B_ID]: sessionsTotal / 2 } + } +}); + +const RESULTS = buildResults(); +/** A second, distinguishable report, so a refresh that lands can be told from one that did not. */ +const REFRESHED_RESULTS = buildResults(120); + +/** + * What a mutation endpoint answers with: the experiment as the server now holds it, without the + * fields it does not echo — so a state that *replaced* the experiment would lose them and one that + * merged it keeps them. + */ +const buildMutationResponse = (): DotExperiment => + ({ + id: EXPERIMENT_ID, + name: RUNNING_EXPERIMENT.name, + status: DotExperimentStatus.ENDED, + trafficProportion: { + type: TrafficProportionTypes.SPLIT_EVENLY, + variants: [buildVariant(DEFAULT_VARIANT_ID), buildVariant(VARIANT_B_ID, true)] + } + }) as DotExperiment; + +describe('DotExperimentsResultsStore', () => { + let spectator: SpectatorService>; + let store: InstanceType; + let dispatcher: Dispatcher; + let httpErrorManager: jest.Mocked; + + const getById = jest.fn(); + const getResults = jest.fn(); + const stop = jest.fn(); + const promoteVariant = jest.fn(); + const messageGet = jest.fn(); + + let routeParams: Params; + + const activatedRouteStub = { + get paramMap() { + return of(convertToParamMap(routeParams)); + } + }; + + const createService = createServiceFactory({ + service: DotExperimentsResultsStore, + providers: [ + // `Dispatcher`/`Events` are `providedIn: 'platform'`, so they outlive TestBed resets + // and a store from a previous test would keep reacting to this test's events. + provideDispatcher(), + mockProvider(DotExperimentsService, { getById, getResults, stop, promoteVariant }), + mockProvider(DotHttpErrorManagerService), + mockProvider(DotMessageService, { get: messageGet }), + { provide: ActivatedRoute, useValue: activatedRouteStub } + ] + }); + + /** + * Creates the store. Called from the tests rather than from a global `beforeEach` because the + * route is read and the whole load flow runs in `onInit`, so every arrangement has to be in + * place first. + */ + const initStore = (experimentId = EXPERIMENT_ID) => { + routeParams = { experimentId }; + spectator = createService(); + store = spectator.service; + dispatcher = spectator.inject(Dispatcher); + httpErrorManager = spectator.inject( + DotHttpErrorManagerService + ) as jest.Mocked; + spectator.flushEffects(); + }; + + /** A screen that has already loaded an experiment and its report. */ + const initLoaded = ( + experiment: DotExperiment = RUNNING_EXPERIMENT, + results: DotExperimentResults = RESULTS + ) => { + getById.mockReturnValue(of(experiment)); + getResults.mockReturnValue(of(results)); + initStore(experiment.id); + }; + + const httpError = (status: number, error: unknown = {}) => + new HttpErrorResponse({ status, error }); + + beforeEach(() => { + jest.resetAllMocks(); + + getById.mockReturnValue(of(RUNNING_EXPERIMENT)); + getResults.mockReturnValue(of(RESULTS)); + stop.mockReturnValue(of(buildMutationResponse())); + promoteVariant.mockReturnValue(of(buildMutationResponse())); + messageGet.mockImplementation((key: string) => key); + }); + + describe('initial load', () => { + it('should load the experiment and its results from the id on the route', () => { + initStore(); + + expect(getById).toHaveBeenCalledWith(EXPERIMENT_ID); + expect(getResults).toHaveBeenCalledWith(EXPERIMENT_ID); + expect(store.experiment()).toBe(RUNNING_EXPERIMENT); + expect(store.results()).toBe(RESULTS); + expect(store.status()).toBe(ComponentStatus.LOADED); + expect(store.$isLoading()).toBe(false); + expect(store.$hasLoadError()).toBe(false); + expect(store.$canRefresh()).toBe(true); + }); + + it.each([ + ['DRAFT', DRAFT_EXPERIMENT], + ['SCHEDULED', SCHEDULED_EXPERIMENT] + ])('should not ask for the results of a %s experiment', (_status, experiment) => { + getById.mockReturnValue(of(experiment)); + + initStore(); + + // The endpoint is uncached and costs two analytics round-trips plus a Monte Carlo run, + // so it is never called before a single session has been recorded (AC10). + expect(getResults).not.toHaveBeenCalled(); + expect(store.experiment()).toBe(experiment); + expect(store.results()).toBeNull(); + expect(store.status()).toBe(ComponentStatus.LOADED); + expect(store.$isWaitingForData()).toBe(true); + expect(store.$canRefresh()).toBe(false); + }); + + it('should stay loading while the results call is in flight', () => { + getResults.mockReturnValue(NEVER); + + initStore(); + + expect(store.status()).toBe(ComponentStatus.LOADING); + expect(store.$isLoading()).toBe(true); + expect(store.results()).toBeNull(); + }); + + it('should end in the error state when the experiment cannot be found', () => { + getById.mockReturnValue(of(undefined)); + + initStore(); + + expect(getResults).not.toHaveBeenCalled(); + expect(store.experiment()).toBeNull(); + expect(store.$hasLoadError()).toBe(true); + expect(store.status()).toBe(ComponentStatus.ERROR); + }); + + it('should keep the screen and report inline when only the results fail to load', () => { + const error = httpError(400); + getResults.mockReturnValue(throwError(() => error)); + + initStore(); + + // The experiment answered, so everything it accounts for — name, status, goal, + // schedule — still renders. Only the report is missing, and it is reported inline + // rather than replacing the screen with an error card. Blanking here would be a + // regression against the screen this one replaces, which degrades the same way when + // `getResults` 400s while `getById` succeeds. + expect(store.status()).toBe(ComponentStatus.LOADED); + expect(store.$hasLoadError()).toBe(false); + expect(store.experiment()).toEqual(RUNNING_EXPERIMENT); + expect(store.results()).toBeNull(); + expect(store.lastRefreshFailed()).toBe(true); + expect(httpErrorManager.handle).toHaveBeenCalledTimes(1); + }); + + it('should title a headerless results failure with the analytics fallback', () => { + getResults.mockReturnValue(throwError(() => httpError(400, { message: 'boom' }))); + + initStore(); + + expect(httpErrorManager.handle).toHaveBeenCalledWith( + expect.objectContaining({ + error: { message: 'boom', header: RESULTS_ERROR_HEADER_KEY } + }) + ); + }); + + it('should keep the header the backend sent when there is one', () => { + getResults.mockReturnValue( + throwError(() => httpError(500, { header: 'Server error' })) + ); + + initStore(); + + expect(httpErrorManager.handle).toHaveBeenCalledWith( + expect.objectContaining({ error: { header: 'Server error' } }) + ); + }); + }); + + describe('refresh', () => { + it('should replace the results and leave the experiment alone', () => { + initLoaded(); + getResults.mockReturnValue(of(REFRESHED_RESULTS)); + + dispatcher.dispatch(pageEvents.refreshRequested()); + + expect(getResults).toHaveBeenCalledTimes(2); + expect(store.results()).toBe(REFRESHED_RESULTS); + expect(store.experiment()).toBe(RUNNING_EXPERIMENT); + expect(getById).toHaveBeenCalledTimes(1); + expect(store.refreshing()).toBe(false); + expect(store.lastRefreshFailed()).toBe(false); + expect(store.status()).toBe(ComponentStatus.LOADED); + }); + + it('should keep the results on screen while the new ones are in flight', () => { + initLoaded(); + getResults.mockReturnValue(NEVER); + + dispatcher.dispatch(pageEvents.refreshRequested()); + + expect(store.refreshing()).toBe(true); + // Never swapped for a skeleton: the report on screen stays put until its replacement + // arrives (AC9). + expect(store.results()).toBe(RESULTS); + expect(store.status()).toBe(ComponentStatus.LOADED); + expect(store.$isLoading()).toBe(false); + }); + + it('should keep the last good results when the refresh fails', () => { + initLoaded(); + getResults.mockReturnValue(throwError(() => httpError(500))); + + dispatcher.dispatch(pageEvents.refreshRequested()); + + // A failed refresh is reported without blanking a screen that has already loaded + // (AC25): the results, and the status behind them, are untouched. + expect(store.results()).toBe(RESULTS); + expect(store.experiment()).toBe(RUNNING_EXPERIMENT); + expect(store.status()).toBe(ComponentStatus.LOADED); + expect(store.$hasLoadError()).toBe(false); + expect(store.lastRefreshFailed()).toBe(true); + expect(store.refreshing()).toBe(false); + expect(httpErrorManager.handle).toHaveBeenCalledTimes(1); + }); + + it('should clear the previous failure when a later refresh lands', () => { + initLoaded(); + getResults.mockReturnValue(throwError(() => httpError(500))); + dispatcher.dispatch(pageEvents.refreshRequested()); + + getResults.mockReturnValue(of(REFRESHED_RESULTS)); + dispatcher.dispatch(pageEvents.refreshRequested()); + + expect(store.lastRefreshFailed()).toBe(false); + expect(store.results()).toBe(REFRESHED_RESULTS); + }); + + it('should ignore a refresh for an experiment that has nothing to report', () => { + initLoaded(DRAFT_EXPERIMENT); + + dispatcher.dispatch(pageEvents.refreshRequested()); + + // The control does not exist on a screen with nothing to refresh (AC9/AC10), and the + // handler drops the event even if something else raises it. + expect(store.$canRefresh()).toBe(false); + expect(getResults).not.toHaveBeenCalled(); + expect(store.results()).toBeNull(); + }); + }); + + describe('promote', () => { + it('should close its buttons while the promotion is on the wire', () => { + initLoaded(); + promoteVariant.mockReturnValue(NEVER); + + dispatcher.dispatch(pageEvents.promoteRequested(VARIANT_B_ID)); + + expect(store.$isSaving()).toBe(true); + expect(store.status()).toBe(ComponentStatus.SAVING); + }); + + it('should merge the already-ended experiment the promotion answered with', () => { + initLoaded(); + + dispatcher.dispatch(pageEvents.promoteRequested(VARIANT_B_ID)); + + expect(promoteVariant).toHaveBeenCalledWith(EXPERIMENT_ID, VARIANT_B_ID); + // Promoting a RUNNING experiment ends it in the same call, so the experiment that + // comes back already reads ENDED and the header re-renders in place (AC20). + expect(store.experiment()?.status).toBe(DotExperimentStatus.ENDED); + expect(store.$status()).toBe(DotExperimentStatus.ENDED); + // Merged, not replaced: what the response omits is still on the experiment. + expect(store.experiment()?.description).toBe(RUNNING_EXPERIMENT.description); + expect(store.experiment()?.pageId).toBe(RUNNING_EXPERIMENT.pageId); + expect(store.$promotedVariant()).toEqual(buildVariant(VARIANT_B_ID, true)); + expect(store.results()).toBe(RESULTS); + expect(store.status()).toBe(ComponentStatus.LOADED); + }); + + it('should leave the screen usable when the promotion fails', () => { + initLoaded(); + const error = httpError(500); + promoteVariant.mockReturnValue(throwError(() => error)); + + dispatcher.dispatch(pageEvents.promoteRequested(VARIANT_B_ID)); + + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + // A rejected mutation changes nothing and can be retried (AC5). + expect(store.experiment()).toBe(RUNNING_EXPERIMENT); + expect(store.$promotedVariant()).toBeNull(); + expect(store.results()).toBe(RESULTS); + expect(store.status()).toBe(ComponentStatus.LOADED); + expect(store.$hasLoadError()).toBe(false); + expect(store.$isSaving()).toBe(false); + }); + }); + + describe('stop', () => { + it('should end the experiment in place', () => { + initLoaded(); + + dispatcher.dispatch(pageEvents.stopRequested()); + + expect(stop).toHaveBeenCalledWith(EXPERIMENT_ID); + expect(store.experiment()?.status).toBe(DotExperimentStatus.ENDED); + expect(store.$status()).toBe(DotExperimentStatus.ENDED); + expect(store.experiment()?.description).toBe(RUNNING_EXPERIMENT.description); + expect(store.results()).toBe(RESULTS); + expect(store.status()).toBe(ComponentStatus.LOADED); + }); + + it('should leave the experiment running and retryable when the stop fails', () => { + initLoaded(); + const error = httpError(500); + stop.mockReturnValue(throwError(() => error)); + + dispatcher.dispatch(pageEvents.stopRequested()); + + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(store.experiment()).toBe(RUNNING_EXPERIMENT); + expect(store.$status()).toBe(DotExperimentStatus.RUNNING); + expect(store.status()).toBe(ComponentStatus.LOADED); + expect(store.$hasLoadError()).toBe(false); + expect(store.$isSaving()).toBe(false); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results.store.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results.store.ts new file mode 100644 index 000000000000..5ad8d77e7241 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-results.store.ts @@ -0,0 +1,456 @@ +import { mapResponse } from '@ngrx/operators'; +import { signalStore, withComputed, withHooks, withState } from '@ngrx/signals'; +import { Dispatcher, Events, on, withEventHandlers, withReducer } from '@ngrx/signals/events'; +import { ChartData } from 'chart.js'; +import { of, SubscriptionLike } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { computed, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { distinctUntilChanged, filter, map, mergeMap, switchMap } from 'rxjs/operators'; + +import { + DotExperimentsService, + DotHttpErrorManagerService, + DotMessageService +} from '@dotcms/data-access'; +import { + BayesianNoWinnerStatus, + BayesianStatusResponse, + ComponentStatus, + DEFAULT_VARIANT_ID, + DotExperiment, + DotExperimentStatus, + DotResultVariant, + MINIMUM_SESSIONS_TO_SHOW_CHART, + ReportSummaryLegendByBayesianStatus, + SummaryLegend, + Variant +} from '@dotcms/dotcms-models'; + +import { dotExperimentsResultsApiEvents } from './dot-experiments-results-api.events'; +import { dotExperimentsResultsPageEvents } from './dot-experiments-results-page.events'; + +import { + buildDailyChartData, + buildDailyChartLabels, + getBayesianDatasets, + getSuggestedWinner +} from '../shared/dot-experiment-results.utils'; +import { DotExperimentResultVariantDetail, DotExperimentsResultsViewState } from '../shared/models'; +import { buildVariantDetails } from '../util/dot-experiments-results.util'; + +const pageEvents = dotExperimentsResultsPageEvents; +const apiEvents = dotExperimentsResultsApiEvents; + +/** + * Statuses with nothing to report yet. They never reach `getResults`: the endpoint is uncached and + * costs two analytics round-trips plus a Monte Carlo run, so it is not called before a single + * session has been recorded (AC10). + */ +const STATUSES_WITHOUT_RESULTS: readonly DotExperimentStatus[] = [ + DotExperimentStatus.DRAFT, + DotExperimentStatus.SCHEDULED +]; + +/** Header the old screen supplies when a rejected results call carries none of its own. */ +const RESULTS_ERROR_HEADER_KEY = + 'dot.common.http.error.400.experiment.analytics-app-not-configured.header'; + +/** Copy for a value the backend has not computed yet, e.g. a range without enough data. */ +const NO_DATA_LABEL_KEY = 'experiments.reports.not.enough.data'; + +/** Word between the two bounds of the 95% conversion rate range. */ +const RANGE_SEPARATOR_LABEL_KEY = 'to'; + +const initialState: DotExperimentsResultsViewState = { + experiment: null, + results: null, + status: ComponentStatus.INIT, + refreshing: false, + lastRefreshFailed: false +}; + +/** + * Store for the Results screen, at `/experiments/:experimentId/results`. + * + * The screen is reachable on any status (AC1), and the experiment itself decides how much of it + * there is to load: DRAFT and SCHEDULED render their waiting state from the experiment alone and + * never ask for results, while everything else loads the report beside it. + * + * Results that have loaded once are never taken off the screen. A failed *first* load is the full + * error state (AC24); a failed *refresh* leaves the last good results exactly where they are and + * only raises `lastRefreshFailed`, which the shell reports without blanking anything (AC25). + * + * The leading variant is always the backend's `bayesianResult.suggestedWinner`, never the highest + * conversion rate: only the backend applies a significance threshold, and a rate-based pick would + * name a winner even when there is none to name (AC8). + * + * State only ever changes through dispatched events (`withReducer`); the store exposes no mutating + * methods and never opens UI — the Stop and Promote confirmations, and the toasts that follow, + * belong to the shell. + * + * Not provided in root: supply it in the Results shell's `providers` together with + * `DotExperimentsService`. + */ +export const DotExperimentsResultsStore = signalStore( + withState(initialState), + withComputed((store) => { + const dotMessageService = inject(DotMessageService); + + const $status = computed( + () => store.experiment()?.status ?? DotExperimentStatus.DRAFT + ); + + /** Nothing has been measured yet, so there is nothing to fetch or to chart (AC10/AC13). */ + const $isWaitingForData = computed(() => + STATUSES_WITHOUT_RESULTS.includes($status()) + ); + + /** + * The threshold below which a report says more than it knows. It gates the daily chart — + * as it always has — and, since AC15, the summary table as a whole. + */ + const $hasEnoughSessions = computed(() => { + const results = store.results(); + + return !!results && results.sessions.total >= MINIMUM_SESSIONS_TO_SHOW_CHART; + }); + + /** The variant the backend suggests, or `null` when it suggests none (AC8). */ + const $suggestedWinner = computed(() => { + const results = store.results(); + const suggestedWinner = results?.bayesianResult?.suggestedWinner; + + if (!results || !suggestedWinner || BayesianNoWinnerStatus.includes(suggestedWinner)) { + return null; + } + + return results.goals.primary.variants[suggestedWinner] ?? null; + }); + + const $bayesianChartData = computed | null>(() => { + const results = store.results(); + + return results ? { datasets: getBayesianDatasets(results) } : null; + }); + + return { + $status, + $isWaitingForData, + $suggestedWinner, + $bayesianChartData, + $isLoading: computed(() => store.status() === ComponentStatus.LOADING), + /** Nothing loaded and the load failed: the only state that blanks the screen (AC24). */ + $hasLoadError: computed(() => store.status() === ComponentStatus.ERROR), + /** A mutation is on the wire, so its buttons stay closed until it settles. */ + $isSaving: computed(() => store.status() === ComponentStatus.SAVING), + /** The refresh control only exists once there is something to refresh (AC9/AC10). */ + $canRefresh: computed(() => !$isWaitingForData() && !!store.results()), + /** + * Which winner copy the stat strip renders, negative states included — icon and i18n + * key both, so `null` never has to be translated into an absence downstream (AC8). + */ + $winnerLegend: computed(() => { + const experiment = store.experiment(); + const results = store.results(); + + return experiment && results + ? getSuggestedWinner(experiment, results) + : { ...ReportSummaryLegendByBayesianStatus.NO_ENOUGH_SESSIONS }; + }), + /** The promoted variant, which only the experiment knows about — never the results. */ + $promotedVariant: computed( + () => + store + .experiment() + ?.trafficProportion?.variants.find(({ promoted }) => promoted) ?? null + ), + /** + * The gate is experiment-wide: below it the whole table is replaced by one empty state, + * and above it every row shows its full data however few sessions it saw (AC15). + */ + $hasEnoughSessionsForTable: $hasEnoughSessions, + /** The same threshold the daily chart has always been gated on. */ + $hasEnoughSessionsForDailyChart: $hasEnoughSessions, + /** + * A posterior distribution can only be drawn once every variant has one: a dataset + * that came back empty would render as a flat line reading like a real result. + */ + $hasEnoughDataForBayesianChart: computed(() => { + const results = store.results(); + const datasets = $bayesianChartData()?.datasets; + + if (!results || !datasets) { + return false; + } + + return ( + results.bayesianResult?.suggestedWinner !== BayesianStatusResponse.NONE && + datasets.every((dataset) => dataset.data.length > 0) + ); + }), + $dailyChartData: computed | null>(() => { + const results = store.results(); + const variants = results?.goals?.primary?.variants; + + // The labels are the control's own days, so a payload without a control has no + // axis to draw against — the empty chart state covers it. + if (!variants?.[DEFAULT_VARIANT_ID]) { + return null; + } + + return { + labels: buildDailyChartLabels(variants, dotMessageService), + datasets: buildDailyChartData(variants) + }; + }), + /** One summary-table row per variant, Lift vs Original included (AC14/AC16). */ + $detailData: computed(() => { + const experiment = store.experiment(); + const results = store.results(); + + if (!experiment || !results?.bayesianResult) { + return []; + } + + return buildVariantDetails(experiment, results, { + noDataLabel: dotMessageService.get(NO_DATA_LABEL_KEY), + rangeSeparatorLabel: dotMessageService.get(RANGE_SEPARATOR_LABEL_KEY) + }); + }) + }; + }), + withReducer( + /** + * A URL arriving while the screen is up drops everything: the component is reused across + * experiments, and results left behind would be read as the new one's until its own + * arrive. + */ + on(pageEvents.enter, () => ({ ...initialState, status: ComponentStatus.LOADING })), + on(apiEvents.loadSucceeded, ({ payload }) => ({ + experiment: payload.experiment, + results: payload.results, + status: ComponentStatus.LOADED + })), + // The experiment itself is missing, so there is nothing to frame a report with: this is the + // one failure that blanks the screen. + on(apiEvents.loadFailed, () => ({ status: ComponentStatus.ERROR })), + /** + * The experiment answered but its report did not. The screen settles as LOADED with a null + * report so the header, goal and schedule still render, and reuses the same flag a failed + * refresh raises to say the report is missing (AC25's mechanism, applied to the first load). + */ + on(apiEvents.resultsUnavailable, ({ payload }) => ({ + experiment: payload, + results: null, + status: ComponentStatus.LOADED, + lastRefreshFailed: true + })), + + // Refresh reports itself, and only itself: `status` stays `LOADED` throughout, so the + // results on screen are never swapped for a skeleton (AC9). + // `refresh$` drops this event for the statuses that never reach `getResults`, so the flag + // must not be raised for them either — it would spin forever with no request in flight. + on(pageEvents.refreshRequested, (_event, state) => + STATUSES_WITHOUT_RESULTS.includes(state.experiment?.status ?? DotExperimentStatus.DRAFT) + ? {} + : { refreshing: true, lastRefreshFailed: false } + ), + on(apiEvents.refreshSucceeded, ({ payload }) => ({ + results: payload, + refreshing: false, + lastRefreshFailed: false + })), + /** + * Deliberately does not touch `results` or `status`: the last good report stays exactly as + * it is and the flag is all the screen needs to say the refresh failed (AC25). + */ + on(apiEvents.refreshFailed, () => ({ refreshing: false, lastRefreshFailed: true })), + + on(pageEvents.stopRequested, pageEvents.promoteRequested, () => ({ + status: ComponentStatus.SAVING + })), + /** + * Both answer with the experiment as the server now holds it, merged rather than replaced + * so nothing the response omits is lost. Promoting a RUNNING experiment ends it in the + * same call, so the experiment merged here already reads ENDED and the header re-renders + * in place, with no navigation and no second call (AC4/AC20). + */ + on(apiEvents.stopSucceeded, apiEvents.promoteSucceeded, ({ payload }, state) => ({ + experiment: { ...state.experiment, ...payload }, + status: ComponentStatus.LOADED + })), + // A rejected mutation changes nothing and leaves the screen usable, so it can be retried + // (AC5). The error itself was already reported by `DotHttpErrorManagerService`. + on(apiEvents.stopFailed, apiEvents.promoteFailed, () => ({ + status: ComponentStatus.LOADED + })) + ), + withEventHandlers( + ( + store, + events = inject(Events), + experimentsService = inject(DotExperimentsService), + httpErrorManager = inject(DotHttpErrorManagerService), + dotMessageService = inject(DotMessageService) + ) => { + /** Routes a failed call through the shared manager, then reports it as its event. */ + const toFailure = + (failed: (error: HttpErrorResponse) => T) => + (error: HttpErrorResponse): T => { + httpErrorManager.handle(error); + + return failed(error); + }; + + /** + * A rejected results call is the analytics app not being configured as often as it is + * anything else, and the backend answers that case without a header — which would + * leave the error dialog titleless. Same fallback the old reports screen supplies. + */ + const toResultsFailure = + (failed: (error: HttpErrorResponse) => T) => + (error: HttpErrorResponse): T => { + httpErrorManager.handle({ + ...error, + error: { + ...error.error, + header: + error.error?.header ?? + dotMessageService.get(RESULTS_ERROR_HEADER_KEY) + } + } as HttpErrorResponse); + + return failed(error); + }; + + return { + /** + * The experiment comes first and decides whether its report is worth asking for: + * DRAFT and SCHEDULED settle on the experiment alone (AC10). The branch reads the + * *status*, not whether results happen to be null — an experiment that never ran + * has no results to skip fetching, and one that did must always fetch them. + * + * Sequential rather than the old screen's `forkJoin`, precisely because the second + * call depends on what the first one answers. + * + * `getById` swallows its own errors into `undefined`, so an experiment that is not + * there arrives as an empty answer rather than as a rejection. + */ + load$: events.on(pageEvents.enter).pipe( + switchMap(({ payload: experimentId }) => + experimentsService.getById(experimentId).pipe( + switchMap((experiment) => { + if (!experiment) { + return of(apiEvents.loadFailed(experimentId)); + } + + if (STATUSES_WITHOUT_RESULTS.includes(experiment.status)) { + return of( + apiEvents.loadSucceeded({ experiment, results: null }) + ); + } + + return experimentsService.getResults(experiment.id).pipe( + mapResponse({ + next: (results) => + apiEvents.loadSucceeded({ experiment, results }), + // The experiment is already in hand, so a report that fails + // costs the report, not the screen. + error: toResultsFailure(() => + apiEvents.resultsUnavailable(experiment) + ) + }) + ); + }) + ) + ) + ), + + /** + * Results only: the experiment cannot change while the screen sits on it, and the + * report is the expensive half. `switchMap` so an impatient second press replaces + * the first request instead of queueing behind it (AC9). + */ + refresh$: events.on(pageEvents.refreshRequested).pipe( + map(() => store.experiment()), + filter( + (experiment): experiment is DotExperiment => + !!experiment && !store.$isWaitingForData() + ), + switchMap((experiment) => + experimentsService.getResults(experiment.id).pipe( + mapResponse({ + next: (results) => apiEvents.refreshSucceeded(results), + error: toResultsFailure(apiEvents.refreshFailed) + }) + ) + ) + ), + + stop$: events.on(pageEvents.stopRequested).pipe( + switchMap(() => + experimentsService.stop(store.experiment()?.id ?? '').pipe( + mapResponse({ + next: (experiment) => apiEvents.stopSucceeded(experiment), + error: toFailure(apiEvents.stopFailed) + }) + ) + ) + ), + + /** + * `mergeMap`, as every per-variant action in this portlet: promoting one row must + * not cancel a call already made for another. The backend ends a RUNNING + * experiment as part of the same call, so there is exactly one request here — + * whatever it answers with is already the ended experiment (AC20). + */ + promote$: events.on(pageEvents.promoteRequested).pipe( + mergeMap(({ payload: variantId }) => + experimentsService + .promoteVariant(store.experiment()?.id ?? '', variantId) + .pipe( + mapResponse({ + next: (experiment) => apiEvents.promoteSucceeded(experiment), + error: toFailure(apiEvents.promoteFailed) + }) + ) + ) + ) + }; + } + ), + withHooks(() => { + const route = inject(ActivatedRoute); + const dispatcher = inject(Dispatcher); + + let routeSubscription: SubscriptionLike; + + return { + onInit() { + /** + * Followed for as long as the screen lives rather than read once: the component is + * reused across experiments, so an id arriving while the screen is up must load + * the experiment it names instead of leaving the previous one on screen. + */ + routeSubscription = route.paramMap + .pipe( + map((params) => params.get('experimentId')), + distinctUntilChanged(), + filter((experimentId): experimentId is string => !!experimentId) + ) + .subscribe((experimentId) => { + dispatcher.dispatch(pageEvents.enter(experimentId)); + }); + }, + onDestroy() { + routeSubscription?.unsubscribe(); + } + }; + }) +); + +/** Injectable type of {@link DotExperimentsResultsStore}, for typing component fields. */ +export type DotExperimentsResultsStore = InstanceType; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-results.util.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-results.util.ts new file mode 100644 index 000000000000..54cb6fffd71a --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-results.util.ts @@ -0,0 +1,151 @@ +import { formatPercent } from '@angular/common'; + +import { + DEFAULT_VARIANT_ID, + DotBayesianVariantResult, + DotCreditabilityInterval, + DotExperiment, + DotExperimentResults, + DotResultVariant +} from '@dotcms/dotcms-models'; + +import { isPromotedVariant } from '../shared/dot-experiment-results.utils'; +import { DotExperimentResultVariantDetail, LiftTone, VariantDetailLabels } from '../shared/models'; + +/** + * Pure helpers behind the Results store: the percentage formats the summary table renders and the + * Lift vs Original the backend does not send. Kept out of the store so each can be read — and + * tested — on its own, without standing up the store or its injected services. + */ + +/** What the control row, and any row measured against a control that never converted, renders. */ +const NO_LIFT_LABEL = '—'; + +/** Lift is expressed in percentage points, so the rates are compared on their percent scale. */ +const PERCENT_SCALE = 100; + +/** + * Below 10% two decimals carry the signal, above it one is enough — same rounding the old reports + * screen used, so a rate reads identically on both. + */ +const getPercentageFormat = (value: number): string => + value < 0.1 ? formatPercent(value, 'en-US', '1.0-2') : formatPercent(value, 'en-US', '1.0-1'); + +/** Share of a variant's sessions that converted, as a `0..1` rate. No sessions is no conversion. */ +export const conversionRateOf = ( + variant: DotResultVariant, + results: DotExperimentResults +): number => { + const sessions = results.sessions.variants[variant.variantName]; + + return sessions ? variant.uniqueBySession.count / sessions : 0; +}; + +/** + * Lift of a variant over the control, in percentage points. + * + * The control has nothing to be lifted over, and a control that never converted gives no baseline + * to measure against — both render an em dash rather than a number that would read as a result + * (AC16). Everything else is signed to one decimal, ties counting as positive. + * + * @param rate - The variant's conversion rate, `0..1` + * @param controlRate - The control's conversion rate, `0..1` + * @param isControl - Whether the row being built is the control itself + */ +export const buildLiftVsOriginal = ( + rate: number, + controlRate: number, + isControl: boolean +): { label: string; tone: LiftTone } => { + if (isControl || controlRate === 0) { + return { label: NO_LIFT_LABEL, tone: 'neutral' }; + } + + const points = (rate - controlRate) * PERCENT_SCALE; + const isGain = points >= 0; + + return { + label: `${isGain ? '+' : ''}${points.toFixed(1)} pts`, + tone: isGain ? 'positive' : 'negative' + }; +}; + +/** + * One summary-table row per variant of the primary goal, control included. + * + * The three payloads it reads name the same variant three different ways — + * `DotResultVariant.variantName`, `DotBayesianVariantResult.variant` and `Variant.id` — so each + * lookup is keyed on `variantName` and translated at the boundary. + * + * @param experiment - Carries `trafficProportion.variants`, the only place `promoted` lives + * @param results - The results payload the rates, ranges and probabilities come from + * @param labels - Already-translated copy for the values the backend cannot supply + */ +export const buildVariantDetails = ( + experiment: DotExperiment, + results: DotExperimentResults, + labels: VariantDetailLabels +): DotExperimentResultVariantDetail[] => { + const variants = results.goals.primary.variants; + const control = variants[DEFAULT_VARIANT_ID]; + const controlRate = control ? conversionRateOf(control, results) : 0; + + return Object.values(variants).map((variant) => { + const bayesianResult = findBayesianVariantResult( + variant.variantName, + results.bayesianResult.results + ); + const rate = conversionRateOf(variant, results); + const lift = buildLiftVsOriginal( + rate, + controlRate, + variant.variantName === DEFAULT_VARIANT_ID + ); + + return { + id: variant.variantName, + name: variant.variantDescription, + conversions: variant.uniqueBySession.count, + conversionRate: formatConversionRate( + variant.uniqueBySession.count, + results.sessions.variants[variant.variantName] + ), + conversionRateRange: formatConversionRateRange( + bayesianResult?.credibilityInterval, + labels + ), + sessions: results.sessions.variants[variant.variantName], + probabilityToBeBest: formatProbabilityToBeBest( + bayesianResult?.probability, + labels.noDataLabel + ), + isWinner: results.bayesianResult.suggestedWinner === variant.variantName, + isPromoted: isPromotedVariant(experiment, variant.variantName), + liftVsOriginal: lift.label, + liftTone: lift.tone + }; + }); +}; + +/** The Bayesian entry for a variant, which names it `variant` rather than `variantName`. */ +const findBayesianVariantResult = ( + variantName: string, + results: DotBayesianVariantResult[] +): DotBayesianVariantResult | undefined => results.find(({ variant }) => variant === variantName); + +/** A variant that converted nothing, or was never served, reads as a flat `0%` rather than blank. */ +const formatConversionRate = (conversions: number, sessions: number): string => + conversions !== 0 && sessions !== 0 ? getPercentageFormat(conversions / sessions) : '0%'; + +/** The 95% credibility interval, or the no-data copy while the backend has not computed one. */ +const formatConversionRateRange = ( + interval: DotCreditabilityInterval | undefined, + { noDataLabel, rangeSeparatorLabel }: VariantDetailLabels +): string => + interval + ? `${getPercentageFormat(interval.lower)} ${rangeSeparatorLabel} ${getPercentageFormat(interval.upper)}` + : noDataLabel; + +/** Zero probability is as meaningless as a missing one here, so both read as no data. */ +const formatProbabilityToBeBest = (probability: number | undefined, noDataLabel: string): string => + probability ? getPercentageFormat(probability) : noDataLabel; From 0164fd09fa9832e0430680f5f852adfe23844925 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 20 Aug 2026 11:04:03 -0400 Subject: [PATCH 3/5] feat(experiments): the results route carries its own analytics gate Wires :experimentId/results, reachable on every status including DRAFT, and hangs dotAnalyticsHealthCheckResolver off this route only so a broken analytics app takes out the report and leaves the list reachable. That resolver is a plain ResolveFn: it reports rather than redirects, so the screen reads the resolved status and renders the misconfiguration state in place of the report. The edit-page-coupled AnalyticsAppGuard is deliberately not reused. DotExperimentsService is provided on the route rather than the component. The resolver injects it and runs in the route injector, before the screen exists, so providing it on the screen alone would still throw NG0201 on activation. configureCommandsOf moves to the list util: the list's row action and the Results header's Configuration button are two ways to the same URL, and a URL spelled out twice is a URL that can drift. --- .../portlet/src/lib/lib.routes.spec.ts | 40 +++++++++++++++---- .../portlet/src/lib/lib.routes.ts | 32 ++++++++++++--- .../portlet/src/lib/shared/constants.ts | 12 ++++++ .../src/lib/util/dot-experiments-list.util.ts | 14 +++++++ 4 files changed, 85 insertions(+), 13 deletions(-) diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.spec.ts index 1fbf90a8b7a9..0ab6f6dfff44 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.spec.ts @@ -1,8 +1,9 @@ import { Route, UrlSegment } from '@angular/router'; +import { DotExperimentsService } from '@dotcms/data-access'; import { ExperimentsConfigProperties } from '@dotcms/dotcms-models'; import { DotExperimentsConfigResolver } from '@dotcms/portlets/dot-experiments/data-access'; -import { DotPushPublishEnvironmentsResolver } from '@dotcms/ui'; +import { dotAnalyticsHealthCheckResolver, DotPushPublishEnvironmentsResolver } from '@dotcms/ui'; import { dotExperimentsPortletRoutes, experimentsConfigureMatcher } from './lib.routes'; @@ -12,6 +13,9 @@ const segmentsOf = (...paths: string[]): UrlSegment[] => describe('dotExperimentsPortletRoutes', () => { const listRoute = dotExperimentsPortletRoutes.find((route) => route.path === '') as Route; const configureRoute = dotExperimentsPortletRoutes.find((route) => !!route.matcher) as Route; + const resultsRoute = dotExperimentsPortletRoutes.find( + (route) => route.path === ':experimentId/results' + ) as Route; it('should expose the list route', () => { expect(listRoute).toBeDefined(); @@ -28,11 +32,16 @@ describe('dotExperimentsPortletRoutes', () => { expect(configureRoute.path).toBeUndefined(); }); - it('should not wire the screens owned by follow-up issues', () => { - // `:id/results` lands with the reports issue. Until then an unimplemented deep link must - // fall through rather than resolve to a blank screen. - expect(dotExperimentsPortletRoutes).toHaveLength(2); - expect(experimentsConfigureMatcher(segmentsOf('abc', 'reports'))).toBeNull(); + it('should expose the Results screen on a plain path', () => { + // Nothing swaps this URL mid-screen, so it needs none of the matcher the Configure screen + // exists for: one experiment, one path, on every status (AC1). + expect(resultsRoute).toBeDefined(); + expect(resultsRoute.loadComponent).toBeDefined(); + expect(resultsRoute.matcher).toBeUndefined(); + }); + + it('should wire the three screens the portlet owns', () => { + expect(dotExperimentsPortletRoutes).toHaveLength(3); }); describe('resolvers', () => { @@ -58,6 +67,23 @@ describe('dotExperimentsPortletRoutes', () => { ]); }); + it('should resolve the analytics health status on the Results screen', () => { + // A plain resolve, not a guard: it reports rather than redirects, so a misconfigured + // analytics app takes out this screen only and the list stays reachable (AC22). + expect(resultsRoute.resolve?.['healthStatus']).toBe(dotAnalyticsHealthCheckResolver); + }); + + it('should provide the service the health resolver injects', () => { + // The resolver is a standalone `ResolveFn` and needs no provider of its own, but it + // runs in the route's injector — where `DotExperimentsService`, `@Injectable()` with + // no `providedIn`, has to exist before the screen does. + expect(resultsRoute.providers).toContain(DotExperimentsService); + }); + + it('should leave the list ungated by the analytics health check', () => { + expect(listRoute.resolve?.['healthStatus']).toBeUndefined(); + }); + it.each([ ['list', () => listRoute], ['configure', () => configureRoute] @@ -106,7 +132,7 @@ describe('experimentsConfigureMatcher', () => { it.each([ ['the list', segmentsOf()], - ['the reports screen owned by a follow-up issue', segmentsOf('abc', 'reports')], + ['the Results screen, which matches on its own path', segmentsOf('abc', 'results')], ['a deeper unknown URL', segmentsOf('abc', 'configuration', 'extra')] ])('should fall through to %s', (_name, segments) => { expect(experimentsConfigureMatcher(segments)).toBeNull(); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.ts index 251d704da937..a0c697f24332 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.ts @@ -1,10 +1,11 @@ import { Routes, UrlMatchResult, UrlSegment } from '@angular/router'; +import { DotExperimentsService } from '@dotcms/data-access'; import { ExperimentsConfigProperties } from '@dotcms/dotcms-models'; import { DotExperimentsConfigResolver } from '@dotcms/portlets/dot-experiments/data-access'; -import { DotPushPublishEnvironmentsResolver } from '@dotcms/ui'; +import { dotAnalyticsHealthCheckResolver, DotPushPublishEnvironmentsResolver } from '@dotcms/ui'; -import { CONFIGURATION_SEGMENT, NEW_EXPERIMENT_SEGMENT } from './shared/constants'; +import { CONFIGURATION_SEGMENT, NEW_EXPERIMENT_SEGMENT, RESULTS_SEGMENT } from './shared/constants'; /** * Matches the two URLs the Configure screen answers on: `new` and `:experimentId/configuration`. @@ -22,7 +23,7 @@ import { CONFIGURATION_SEGMENT, NEW_EXPERIMENT_SEGMENT } from './shared/constant * * @param segments - Segments left to match under `/experiments` * @returns The consumed segments (plus `experimentId` when present), or `null` to let the list - * route and any future sibling — `:experimentId/reports` — match instead + * route and its sibling — `:experimentId/results` — match instead */ export function experimentsConfigureMatcher(segments: UrlSegment[]): UrlMatchResult | null { if (segments.length === 1 && segments[0].path === NEW_EXPERIMENT_SEGMENT) { @@ -42,9 +43,8 @@ export function experimentsConfigureMatcher(segments: UrlSegment[]): UrlMatchRes * This is the portlet that replaces the per-page UVE experiments screens, which live on * under `./old/` and keep serving `dotExperimentsRoutes` unchanged until they are retired. * - * The list and the Configure screen are wired. `:id/results` is delivered by a follow-up issue - * and is intentionally absent so the router surfaces an honest 404 instead of falling back to - * the legacy UVE screens. + * The list, the Configure screen and the Results screen are wired here; nothing falls back to the + * legacy UVE screens. */ export const dotExperimentsPortletRoutes: Routes = [ { @@ -88,5 +88,25 @@ export const dotExperimentsPortletRoutes: Routes = [ import('./dot-experiments-configure/dot-experiments-configure.component').then( (m) => m.DotExperimentsConfigureComponent ) + }, + { + path: `:experimentId/${RESULTS_SEGMENT}`, + title: 'experiment.container.report.title', + // `dotAnalyticsHealthCheckResolver` is a standalone `ResolveFn`, so it needs no provider of + // its own — but it injects `DotExperimentsService`, which is `@Injectable()` without + // `providedIn: 'root'`. Resolvers run in the route's injector, not the component's, so + // providing it on the screen alone would still throw NG0201 before the screen exists. + providers: [DotExperimentsService], + resolve: { + // Deliberately a plain resolve rather than a guard: it does not redirect, it reports. + // The screen reads `healthStatus` off the route and renders the analytics + // misconfiguration state in place of the results when it is not `OK`, so a broken + // analytics app takes out this screen only — the list stays reachable (AC22). + healthStatus: dotAnalyticsHealthCheckResolver + }, + loadComponent: () => + import('./dot-experiments-results/dot-experiments-results.component').then( + (m) => m.DotExperimentsResultsComponent + ) } ]; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/constants.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/constants.ts index 38a78107c674..68a8b69e9e66 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/constants.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/constants.ts @@ -108,6 +108,9 @@ export const NEW_EXPERIMENT_SEGMENT = 'new'; /** Trailing segment of the Configure URL of an experiment that already exists. */ export const CONFIGURATION_SEGMENT = 'configuration'; +/** Trailing segment of the Results URL. Reachable on every status, including DRAFT (AC1). */ +export const RESULTS_SEGMENT = 'results'; + /** * Multiplier applied to the page-lookup limit. * @@ -245,3 +248,12 @@ export const ADD_VARIANT_DIALOG_WIDTH = '440px'; * store's is what turns it into a scroll target (AC28) — and the card reads both. */ export const WEIGHTS_TOTAL_ERROR_KIND = 'weightsTotal'; + +/** + * Key of the Results screen's `p-confirmDialog`, which the Stop confirmation is raised on. + * + * Its own key rather than the Configure screen's `CONFIGURATION_CONFIRM_DIALOG_KEY`: the two + * screens never share a dialog instance, and the summary table mounts a second dialog of its own + * for Promote — a key shared between two mounted dialogs opens both at once. + */ +export const RESULTS_CONFIRM_DIALOG_KEY = 'resultsConfirmDialog'; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list.util.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list.util.ts index 8dc143fddac3..4fe240e35e2c 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list.util.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list.util.ts @@ -7,6 +7,7 @@ import { type TrafficProportion } from '@dotcms/dotcms-models'; +import { CONFIGURATION_SEGMENT, EXPERIMENTS_URL } from '../shared/constants'; import { DotExperimentPageInfo, ExperimentListAction } from '../shared/models'; /** Day-level format shared by every schedule cell of the experiments list (e.g. `Jun 25, 2026`). */ @@ -102,3 +103,16 @@ function toDisplayDate(epochMillis: number | null | undefined, locale?: string): export function isAllowed(action: ExperimentListAction, status: DotExperimentStatus): boolean { return AllowedActionsByExperimentStatus[action].includes(status); } + +/** + * Router commands for the Configure screen of an experiment that already exists. + * + * Shared rather than repeated: the list's row action and the Results header's Configuration button + * are two ways to the same URL, and a URL spelled out twice is a URL that can drift. + * + * @param experimentId - Identifier of the experiment to configure + * @returns Absolute router commands, since Configure always hangs off the portlet root + */ +export function configureCommandsOf(experimentId: string): string[] { + return [EXPERIMENTS_URL, experimentId, CONFIGURATION_SEGMENT]; +} From edd69cb95a046a9d17fac3a965a0f2b364474e8c Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 20 Aug 2026 11:04:18 -0400 Subject: [PATCH 4/5] feat(experiments): the results screen Shell, header, stat strip, Daily/Bayesian tabs and summary table, per the approved prototype. Both charts reuse the relocated Chart.js options and legend plugin; the prototype's inline SVG is not reimplemented and the Bayesian posterior comes entirely from the backend. The two charts render in @if/@else so exactly one canvas is ever mounted: the legend plugin resolves its container by walking up from the canvas and calling querySelector, which from a shared ancestor returns the first match in document order, so two mounted charts would silently claim each other's legend. The stat strip is presentational and takes the winner legend from the backend's suggested winner, never from a locally computed highest rate, so "no winner yet" renders instead of a false Leading Variant claim. Goal and period come off the experiment, so they still read when the report does not. The session gate is experiment-wide: below ten total sessions the whole summary is one empty state, with no per-row filtering above it. Promote asks first from both entry points -- the strip's inline shortcut and the table's per-row button -- since promoting a RUNNING experiment ends it server-side in the same call. The table hosts its own dialog so the shell and the table never share a key, which would open both at once. --- ...-experiments-results-charts.component.html | 37 +++ ...ot-experiments-results-charts.component.ts | 79 ++++++ ...-experiments-results-header.component.html | 67 +++++ ...ot-experiments-results-header.component.ts | 183 ++++++++++++ ...eriments-results-stat-strip.component.html | 90 ++++++ ...xperiments-results-stat-strip.component.ts | 115 ++++++++ ...ments-results-summary-table.component.html | 99 +++++++ ...ts-results-summary-table.component.spec.ts | 233 +++++++++++++++ ...riments-results-summary-table.component.ts | 152 ++++++++++ .../dot-experiments-results.component.html | 91 ++++++ .../dot-experiments-results.component.scss | 27 ++ .../dot-experiments-results.component.ts | 267 ++++++++++++++++++ .../WEB-INF/messages/Language.properties | 21 ++ 13 files changed, 1461 insertions(+) create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-charts/dot-experiments-results-charts.component.html create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-charts/dot-experiments-results-charts.component.ts create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-header/dot-experiments-results-header.component.html create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-header/dot-experiments-results-header.component.ts create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-stat-strip/dot-experiments-results-stat-strip.component.html create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-stat-strip/dot-experiments-results-stat-strip.component.ts create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component.html create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component.spec.ts create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component.ts create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/dot-experiments-results.component.html create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/dot-experiments-results.component.scss create mode 100644 core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/dot-experiments-results.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-charts/dot-experiments-results-charts.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-charts/dot-experiments-results-charts.component.html new file mode 100644 index 000000000000..e19f14d965ad --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-charts/dot-experiments-results-charts.component.html @@ -0,0 +1,37 @@ +
+
+ @for (tab of tabs; track tab.id) { + + } +
+ + @if ($activeTab() === 'daily') { + + } @else { + + } +
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-charts/dot-experiments-results-charts.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-charts/dot-experiments-results-charts.component.ts new file mode 100644 index 000000000000..82313fefd9d1 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-charts/dot-experiments-results-charts.component.ts @@ -0,0 +1,79 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotExperimentsReportsChartComponent } from '../../../shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component'; +import { DotExperimentsResultsStore } from '../../../store/dot-experiments-results.store'; + +/** The two reports the Results screen charts, one per tab. */ +type ResultsChartTab = 'daily' | 'bayesian'; + +/** Axis labels the chart needs as plain strings, so they are resolved once per chart. */ +interface ChartAxisLabels { + xAxisLabel: string; + yAxisLabel: string; +} + +/** + * The charts half of the Results screen: Daily results and Bayesian results behind two tabs. + * + * Only the selected tab is rendered. The chart's legend is drawn by a Chart.js plugin that walks + * *up* from the canvas until it finds a `.legend-wrapper`, and that walk also inspects siblings — + * with both charts mounted at once, one could claim the other's wrapper and a legend would silently + * go missing. `@if` keeps exactly one canvas in the tree, so each chart can only ever find its own. + * + * For the same reason the chart component is composed as a plain child: nothing here wraps or + * re-projects its internals. + * + * Both charts are read-only views of the store — the Bayesian curves arrive already computed from + * the backend, and nothing is derived from them here. + */ +@Component({ + selector: 'dot-experiments-results-charts', + imports: [DotExperimentsReportsChartComponent, DotMessagePipe], + templateUrl: './dot-experiments-results-charts.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'block w-full' + } +}) +export class DotExperimentsResultsChartsComponent { + readonly #dotMessageService = inject(DotMessageService); + + protected readonly store = inject(DotExperimentsResultsStore); + + protected readonly $activeTab = signal('daily'); + + protected readonly tabs: readonly { id: ResultsChartTab; labelKey: string }[] = [ + { id: 'daily', labelKey: 'experiments.reports.chart.title' }, + { id: 'bayesian', labelKey: 'experiments.bayesian.reports.chart.title' } + ]; + + protected readonly dailyAxisLabels: ChartAxisLabels = { + xAxisLabel: this.#dotMessageService.get('experiments.chart.xAxisLabel'), + yAxisLabel: this.#dotMessageService.get('experiments.chart.yAxisLabel') + }; + + protected readonly bayesianAxisLabels: ChartAxisLabels = { + xAxisLabel: this.#dotMessageService.get('experiments.chart.xAxisLabel.bayesian'), + yAxisLabel: this.#dotMessageService.get('experiments.chart.yAxisLabel.bayesian') + }; + + /** + * A chart with too few sessions, or with no payload at all — DRAFT and SCHEDULED included, + * where no results are ever fetched — hands the empty state to the chart component rather than + * drawing an axis nothing sits on. + */ + protected readonly $isDailyEmpty = computed( + () => !this.store.$hasEnoughSessionsForDailyChart() || !this.store.$dailyChartData() + ); + + protected readonly $isBayesianEmpty = computed( + () => !this.store.$hasEnoughDataForBayesianChart() || !this.store.$bayesianChartData() + ); + + protected selectTab(tab: ResultsChartTab): void { + this.$activeTab.set(tab); + } +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-header/dot-experiments-results-header.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-header/dot-experiments-results-header.component.html new file mode 100644 index 000000000000..bdacc865a776 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-header/dot-experiments-results-header.component.html @@ -0,0 +1,67 @@ +
+ + + + +
+
+

+ {{ $title() }} +

+ +
+ + {{ $subline() }} + +
+
+ +
+ + + + + @if ($showStop()) { + + + + } +
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-header/dot-experiments-results-header.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-header/dot-experiments-results-header.component.ts new file mode 100644 index 000000000000..0dd0990fe821 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-header/dot-experiments-results-header.component.ts @@ -0,0 +1,183 @@ +import { injectDispatch } from '@ngrx/signals/events'; +import { Observable, of } from 'rxjs'; + +import { Component, computed, inject } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { Router } from '@angular/router'; + +import { ConfirmationService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { TagModule } from 'primeng/tag'; +import { TooltipModule } from 'primeng/tooltip'; + +import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators'; + +import { DotContentSearchService, DotMessageService } from '@dotcms/data-access'; +import { DotCMSContentlet, DotExperimentStatus } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; +import { isDotIdentifier } from '@dotcms/utils'; + +import { + EXPERIMENTS_URL, + RESULTS_CONFIRM_DIALOG_KEY, + STATUS_LABEL_KEYS, + STATUS_SEVERITIES +} from '../../../shared/constants'; +import { DotExperimentConfigurePage, TagSeverity } from '../../../shared/models'; +import { dotExperimentsResultsPageEvents } from '../../../store/dot-experiments-results-page.events'; +import { DotExperimentsResultsStore } from '../../../store/dot-experiments-results.store'; +import { toConfigurePage } from '../../../util/dot-experiments-configure.util'; +import { configureCommandsOf, variantsCount } from '../../../util/dot-experiments-list.util'; + +/** Shape of the `/api/content/_search` entity the page lookup reads contentlets from. */ +interface PageLookupEntity { + jsonObjectView?: { contentlets?: DotCMSContentlet[] }; +} + +/** + * Separator of the three parts of the subline: middle dot U+00B7 with a space either side, as the + * design specifies. Not the en dash the Period uses, and not a pipe. + */ +const SUBLINE_SEPARATOR = ' · '; + +/** Trailing part of the subline: the variant count, always in its plural form. */ +const SUBLINE_VARIANTS_KEY = 'experiments.results.header.variants'; + +/** + * Header of the Results screen: back, name, status, the page the experiment runs on, and the two + * actions this screen offers. + * + * The store is injected rather than received through inputs — the Results shell provides it, so + * every part of the screen reads the same instance. + * + * Only Stop is raised from here, and only while the experiment is RUNNING (AC3). The confirmation + * goes to the shell's `p-confirmDialog` by key, as the Configure header's does: this component + * renders no dialog of its own, and the toast that follows belongs to the shell, which listens for + * the API event. + */ +@Component({ + selector: 'dot-experiments-results-header', + imports: [ButtonModule, TagModule, TooltipModule, DotMessagePipe], + templateUrl: './dot-experiments-results-header.component.html', + host: { + class: 'flex flex-none items-center justify-between gap-6 border-b border-surface-200 bg-white px-8 py-4' + } +}) +export class DotExperimentsResultsHeaderComponent { + readonly store = inject(DotExperimentsResultsStore); + + readonly $title = computed(() => this.store.experiment()?.name ?? ''); + + readonly $statusSeverity = computed( + () => STATUS_SEVERITIES[this.store.$status()] ?? 'secondary' + ); + + readonly $statusLabelKey = computed( + () => STATUS_LABEL_KEYS.get(this.store.$status()) ?? '' + ); + + /** Stopping ends data collection, so it only applies while data is being collected (AC3). */ + readonly $showStop = computed( + () => this.store.$status() === DotExperimentStatus.RUNNING + ); + + readonly #dispatch = injectDispatch(dotExperimentsResultsPageEvents); + readonly #router = inject(Router); + readonly #confirmationService = inject(ConfirmationService); + readonly #dotMessageService = inject(DotMessageService); + readonly #contentSearchService = inject(DotContentSearchService); + + /** + * The page the experiment runs on, resolved from its `pageId`. + * + * `DotExperiment` carries the identifier and nothing else — no title, no path — so the same + * content search the list uses for its Page column resolves them here. Ancillary to the + * screen: a page that cannot be resolved leaves the subline reading the variant count alone + * rather than blocking a report that is otherwise complete. + */ + readonly #page = toSignal( + toObservable(computed(() => this.store.experiment()?.pageId ?? null)).pipe( + distinctUntilChanged(), + switchMap((pageId) => this.#lookupPage(pageId)) + ), + { initialValue: null } + ); + + /** `{pageTitle} · {pagePath} · {n} Variants`, dropping whichever parts are not known yet (AC2). */ + readonly $subline = computed(() => { + const page = this.#page(); + const variants = variantsCount(this.store.experiment()?.trafficProportion); + + return [ + page?.title, + page?.path, + this.#dotMessageService.get(SUBLINE_VARIANTS_KEY, String(variants)) + ] + .filter(Boolean) + .join(SUBLINE_SEPARATOR); + }); + + /** Leaves the Results screen for the list. */ + onBackToList(): void { + this.#router.navigate([EXPERIMENTS_URL]); + } + + /** Opens the Configure screen of the experiment being reported on (AC2). */ + onConfiguration(): void { + const experimentId = this.store.experiment()?.id; + + if (experimentId) { + this.#router.navigate(configureCommandsOf(experimentId)); + } + } + + /** + * Asks before ending the experiment, then hands the transition to the store. + * + * The copy says what ending costs — data collection stops there and then — since the button is + * only reachable while sessions are still being counted (AC3). + * + * Raised on the shell's `p-confirmDialog` by `RESULTS_CONFIRM_DIALOG_KEY`, as the Configure + * header raises its own: this component renders no dialog. The key is what keeps the two + * confirmations of this screen apart — the summary table mounts an unkeyed dialog for Promote, + * so a keyed request reaches this one and only this one (AC21). + */ + confirmStop(): void { + this.#confirmationService.confirm({ + key: RESULTS_CONFIRM_DIALOG_KEY, + header: this.#dotMessageService.get('experiments.action.stop-experiment'), + message: this.#dotMessageService.get('experiments.results.stop.confirm-message'), + acceptLabel: this.#dotMessageService.get('experiments.action.end'), + rejectLabel: this.#dotMessageService.get('dot.common.dialog.reject'), + rejectButtonStyleClass: 'p-button-secondary', + defaultFocus: 'reject', + closable: true, + closeOnEscape: true, + accept: () => this.#dispatch.stopRequested() + }); + } + + /** + * Resolves a page identifier to the title and path the subline renders. + * + * The identifier is concatenated into a Lucene query, so anything outside the identifier shape + * is answered as "not found" rather than widening the search — same guard the Configure screen + * applies to the `?pageId=` it is handed. + */ + #lookupPage(pageId: string | null): Observable { + if (!pageId || !isDotIdentifier(pageId)) { + return of(null); + } + + return this.#contentSearchService + .get({ + query: `+contentType:htmlpageasset +working:true +identifier:${pageId}`, + limit: 1 + }) + .pipe( + map((entity) => entity?.jsonObjectView?.contentlets?.[0]), + map((contentlet) => (contentlet ? toConfigurePage(contentlet) : null)), + catchError(() => of(null)) + ); + } +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-stat-strip/dot-experiments-results-stat-strip.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-stat-strip/dot-experiments-results-stat-strip.component.html new file mode 100644 index 000000000000..7d69f03e6356 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-stat-strip/dot-experiments-results-stat-strip.component.html @@ -0,0 +1,90 @@ +
+
+ @if (!$isWaitingForData()) { +
+ + {{ + ($isEnded() + ? 'experiments.results.stat-strip.winner' + : 'experiments.results.stat-strip.leading-variant' + ) | dm + }} + +
+ @if ($hasWinnerIcon()) { + + } @else { + + block + + } + + @if ($canPromote()) { + + } +
+
+ } +
+ + {{ 'experiments.summary.winner.testing' | dm }} + + {{ $goalName() ?? '—' }} +
+
+ + {{ 'experiments.results.stat-strip.period' | dm }} + + + @if ($scheduling()?.startDate; as startDate) { + {{ startDate | date: 'longDate' }} + @if ($scheduling()?.endDate; as endDate) { + + {{ endDate | date: 'longDate' }} + } + } @else { + — + } + +
+
+ + {{ 'experiments.summary.winner.session-to-date' | dm }} + + + {{ $sessionsReached() | number }} + +
+
+ @if ($canRefresh()) { +
+ +
+ } +
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-stat-strip/dot-experiments-results-stat-strip.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-stat-strip/dot-experiments-results-stat-strip.component.ts new file mode 100644 index 000000000000..2dbb8973142e --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-stat-strip/dot-experiments-results-stat-strip.component.ts @@ -0,0 +1,115 @@ +import { DatePipe, DecimalPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { TooltipModule } from 'primeng/tooltip'; + +import { + DEFAULT_VARIANT_ID, + DotExperimentStatus, + DotResultVariant, + RangeOfDateAndTime, + SummaryLegend, + Variant +} from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +/** Icon the legend carries when the backend did suggest a winner. */ +const WINNER_LEGEND_ICON = 'dot-trophy'; + +/** Explains why the leader may be promoted: the backend already cleared the 95% threshold. */ +const THRESHOLD_MET_KEY = 'experiments.results.stat-strip.threshold-met'; + +/** Explains why there is no leader to promote yet. */ +const THRESHOLD_NOT_MET_KEY = 'experiments.results.stat-strip.threshold-not-met'; + +/** + * The four numbers a report is read by, in one strip above the charts: the winning or leading + * variant, the goal being measured, the period measured over, and the sessions counted so far. + * + * Purely presentational — the Results shell owns the store and wires every input, so the strip can + * be rendered from any state, including the ones with nothing to show. + * + * The leader is whatever the backend suggested (`winnerLegend` / `suggestedWinner`), never the + * highest conversion rate: only the backend applies the significance threshold, and a rate-based + * pick would always name someone, leaving the "no winner yet" state unreachable (AC8). + */ +@Component({ + selector: 'dot-experiments-results-stat-strip', + imports: [DatePipe, DecimalPipe, ButtonModule, TooltipModule, DotMessagePipe], + templateUrl: './dot-experiments-results-stat-strip.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotExperimentsResultsStatStripComponent { + /** Status of the experiment being reported on; decides Winner vs Leading Variant. */ + $status = input.required({ alias: 'status' }); + + /** Icon and i18n key for the winner copy, negative states included — never `null` downstream. */ + $winnerLegend = input(null, { alias: 'winnerLegend' }); + + /** The variant the backend suggested, or `null` when it suggested none. */ + $suggestedWinner = input(null, { alias: 'suggestedWinner' }); + + /** The already promoted variant, if any: promoting twice is not offered. */ + $promotedVariant = input(null, { alias: 'promotedVariant' }); + + /** Name of the primary goal the experiment measures. */ + $goalName = input(null, { alias: 'goalName' }); + + /** Start and end of the measured period. */ + $scheduling = input(null, { alias: 'scheduling' }); + + /** Sessions counted so far across every variant. */ + $sessionsReached = input(0, { alias: 'sessionsReached' }); + + /** Nothing has been measured yet: no winner tile and no refresh control (AC10). */ + $isWaitingForData = input(false, { alias: 'isWaitingForData' }); + + /** There are results on screen worth re-fetching (AC9). */ + $canRefresh = input(false, { alias: 'canRefresh' }); + + /** A refresh is on the wire; the control stays closed until it settles. */ + $refreshing = input(false, { alias: 'refreshing' }); + + /** A mutation is on the wire; Promote stays closed until it settles. */ + $isSaving = input(false, { alias: 'isSaving' }); + + /** The refresh control was pressed. */ + refreshRequested = output(); + + /** Promote was pressed, carrying the id of the variant to publish. */ + promoteRequested = output(); + + /** The experiment is over, so its leader is final. */ + protected readonly $isEnded = computed( + () => this.$status() === DotExperimentStatus.ENDED + ); + + /** A winner was suggested — the only state that may claim a leader (AC8). */ + protected readonly $hasSuggestedWinner = computed(() => !!this.$suggestedWinner()); + + /** The legend names the winner, so the variant's description fills its placeholder. */ + protected readonly $winnerLegendArgs = computed(() => [ + this.$suggestedWinner()?.variantDescription ?? '' + ]); + + /** The trophy belongs to a suggested winner; every other state gets the negative icon. */ + protected readonly $hasWinnerIcon = computed( + () => this.$winnerLegend()?.icon === WINNER_LEGEND_ICON + ); + + protected readonly $thresholdHintKey = computed(() => + this.$hasSuggestedWinner() ? THRESHOLD_MET_KEY : THRESHOLD_NOT_MET_KEY + ); + + /** The control is already the published content, and a promoted experiment is settled. */ + protected readonly $canPromote = computed(() => { + const suggestedWinner = this.$suggestedWinner(); + + return ( + !!suggestedWinner && + suggestedWinner.variantName !== DEFAULT_VARIANT_ID && + !this.$promotedVariant() + ); + }); +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component.html new file mode 100644 index 000000000000..d539a93c43ca --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component.html @@ -0,0 +1,99 @@ +
+
+ + +
+ {{ 'experiments.promote.variant' | dm }} +
+
+ {{ 'experiments.reports.sessions' | dm }} +
+
+ {{ 'experiments.reports.conversions' | dm }} +
+
+ {{ 'experiments.reports.conversions.rate' | dm }} +
+
+ {{ 'experiments.results.summary.column.lift' | dm }} +
+
+ {{ 'experiments.reports.probability.best' | dm }} +
+
+ {{ 'experiments.reports.conversion.rate.range' | dm }} +
+
+ {{ 'experiments.reports.promote' | dm }} +
+
+ + +
+ + {{ row.name }} + @if (row.isControl) { + + } + @if (row.isWinner) { + + } + @if (row.isPromoted) { + + } +
+
+ {{ row.sessions }} +
+
+ {{ row.conversions }} +
+
+ {{ row.conversionRate }} +
+
+ {{ row.liftVsOriginal }} +
+
+ {{ row.probabilityToBeBest }} +
+
+ {{ row.conversionRateRange }} +
+
+ @if ($canPromote() && !row.isControl) { + + } +
+
+
+
+
+ + diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component.spec.ts new file mode 100644 index 000000000000..f2db10cfbbb5 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component.spec.ts @@ -0,0 +1,233 @@ +import { Dispatcher } from '@ngrx/signals/events'; +import { byTestId, createComponentFactory, Spectator } from '@openng/spectator/jest'; + +import { signal } from '@angular/core'; + +import { Confirmation, ConfirmationService } from 'primeng/api'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DEFAULT_VARIANT_ID, DotExperimentStatus, Variant } from '@dotcms/dotcms-models'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotExperimentsResultsSummaryTableComponent } from './dot-experiments-results-summary-table.component'; + +import { DotExperimentResultVariantDetail } from '../../../shared/models'; +import { dotExperimentsResultsPageEvents } from '../../../store/dot-experiments-results-page.events'; +import { DotExperimentsResultsStore } from '../../../store/dot-experiments-results.store'; + +const NO_LIFT = '—'; +const VARIANT_ID = 'variant-b'; + +const ABOVE_THRESHOLD_COPY = 'The result clears the 95% threshold.'; +const BELOW_THRESHOLD_COPY = 'Below the 95% threshold.'; +const ENDS_EXPERIMENT_COPY = 'Promoting now ends the Experiment automatically.'; + +const messageServiceMock = new MockDotMessageService({ + 'experiments.promote.variant': 'Variant', + 'experiments.reports.sessions': 'Sessions', + 'experiments.reports.conversions': 'Conversions', + 'experiments.reports.conversions.rate': 'Conversion Rate', + 'experiments.reports.probability.best': 'Probability to be Best', + 'experiments.reports.conversion.rate.range': 'Conversion Rate Range (95%)', + 'experiments.reports.promote': 'Promote', + 'experiments.results.summary.column.lift': 'Lift vs Original', + 'experiments.results.summary.chip.leading': 'LEADING', + 'experiments.results.summary.chip.promoted': 'Promoted', + 'experiments.configure.variants.control-chip': 'CONTROL', + 'experiments.results.promote.confirm.header': 'Promote Variant', + 'experiments.results.promote.confirm.above-threshold': ABOVE_THRESHOLD_COPY, + 'experiments.results.promote.confirm.below-threshold': BELOW_THRESHOLD_COPY, + 'experiments.results.promote.confirm.ends-experiment': ENDS_EXPERIMENT_COPY, + 'dot.common.dialog.reject': 'Cancel' +}); + +/** The control, always expected first however the results happen to order it. */ +const CONTROL_ROW: DotExperimentResultVariantDetail = { + id: DEFAULT_VARIANT_ID, + name: 'Original', + conversions: 12, + conversionRate: '12%', + conversionRateRange: '9% to 15%', + sessions: 100, + probabilityToBeBest: '20%', + isWinner: false, + isPromoted: false, + liftVsOriginal: NO_LIFT, + liftTone: 'neutral' +}; + +/** A variant with far fewer sessions than the control: the gate is experiment-wide, not per row. */ +const VARIANT_ROW: DotExperimentResultVariantDetail = { + id: VARIANT_ID, + name: 'Variant B', + conversions: 2, + conversionRate: '25%', + conversionRateRange: '10% to 40%', + sessions: 8, + probabilityToBeBest: '96%', + isWinner: true, + isPromoted: false, + liftVsOriginal: '+13.0 pts', + liftTone: 'positive' +}; + +const PROMOTED_VARIANT: Variant = { + id: VARIANT_ID, + name: 'Variant B', + weight: 50, + promoted: true, + url: '' +}; + +/** + * Real signals, not `jest.fn()`: the component is OnPush, so a plain mock whose return value is + * swapped after the first render never reaches the template. `set()` marks it dirty the way the + * real store does. + */ +const createStoreMock = () => ({ + $detailData: signal([VARIANT_ROW, CONTROL_ROW]), + $hasEnoughSessionsForTable: signal(true), + $promotedVariant: signal(null), + $status: signal(DotExperimentStatus.RUNNING), + $isLoading: signal(false), + $isSaving: signal(false) +}); + +describe('DotExperimentsResultsSummaryTableComponent', () => { + let spectator: Spectator; + let storeMock: ReturnType; + let dispatch: jest.SpyInstance; + let confirm: jest.SpyInstance; + + const createComponent = createComponentFactory({ + component: DotExperimentsResultsSummaryTableComponent, + providers: [ + { provide: DotExperimentsResultsStore, useFactory: () => storeMock }, + { provide: DotMessageService, useValue: messageServiceMock } + ], + detectChanges: false + }); + + /** `injectDispatch` appends a scope argument, so only the event itself is compared. */ + const dispatchedEvents = () => dispatch.mock.calls.map(([event]) => event); + + const textsOf = (testId: string): string[] => + spectator.queryAll(byTestId(testId)).map((element) => element.textContent?.trim() ?? ''); + + const clickPromote = (index = 0) => { + const host = spectator.queryAll(byTestId('summary-row-promote-btn'))[index]; + spectator.click(host?.querySelector('button') as HTMLElement); + spectator.detectChanges(); + }; + + /** Accepts the confirmation opened by the last Promote and returns it. */ + const acceptConfirmation = (): Confirmation => { + const confirmation = confirm.mock.calls[0][0] as Confirmation; + confirmation.accept?.(); + + return confirmation; + }; + + beforeEach(() => { + storeMock = createStoreMock(); + spectator = createComponent(); + dispatch = jest.spyOn(spectator.inject(Dispatcher), 'dispatch'); + const confirmationService = spectator.inject(ConfirmationService, true); + confirm = jest + .spyOn(confirmationService, 'confirm') + .mockReturnValue(confirmationService) as jest.SpyInstance; + spectator.detectChanges(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('session gate', () => { + it('replaces the whole table with one empty state below the threshold', () => { + storeMock.$hasEnoughSessionsForTable.set(false); + spectator.detectChanges(); + + expect(spectator.queryAll(byTestId('empty-template')).length).toBe(1); + expect(spectator.queryAll(byTestId('detail-row')).length).toBe(0); + }); + + it('shows every row in full above the threshold, however few sessions a row saw', () => { + expect(spectator.query(byTestId('empty-template'))).toBeNull(); + expect(spectator.queryAll(byTestId('detail-row')).length).toBe(2); + expect(textsOf('summary-row-sessions')).toEqual(['100', '8']); + expect(textsOf('summary-row-conversion-rate')).toEqual(['12%', '25%']); + }); + }); + + describe('rows', () => { + it('draws the control first whatever order the results arrive in', () => { + expect(textsOf('summary-row-conversions')).toEqual(['12', '2']); + expect(spectator.queryAll(byTestId('summary-row-control-chip')).length).toBe(1); + }); + + it('renders the lift exactly as it was built, em dash included', () => { + expect(textsOf('summary-row-lift')).toEqual([NO_LIFT, '+13.0 pts']); + }); + + it('colours the lift by its tone', () => { + const [control, variant] = spectator.queryAll(byTestId('summary-row-lift')); + + expect(control).toHaveClass('text-surface-400'); + expect(variant).toHaveClass('text-green-800'); + }); + + it('chips the backend-suggested winner as leading', () => { + expect(spectator.queryAll(byTestId('summary-row-leading-chip')).length).toBe(1); + }); + }); + + describe('promote', () => { + it('offers Promote on every variant but the control', () => { + expect(spectator.queryAll(byTestId('summary-row-promote-btn')).length).toBe(1); + }); + + it('dispatches promoteRequested with the variant id once confirmed', () => { + clickPromote(); + acceptConfirmation(); + + expect(dispatchedEvents()).toEqual([ + dotExperimentsResultsPageEvents.promoteRequested(VARIANT_ID) + ]); + }); + + it('says the experiment will be ended while it is still running', () => { + clickPromote(); + + expect(acceptConfirmation().message).toBe( + `${ABOVE_THRESHOLD_COPY} ${ENDS_EXPERIMENT_COPY}` + ); + }); + + it('omits the ending copy once the experiment has ended', () => { + storeMock.$status.set(DotExperimentStatus.ENDED); + spectator.detectChanges(); + clickPromote(); + + expect(acceptConfirmation().message).toBe(ABOVE_THRESHOLD_COPY); + }); + + it('warns when the result has not cleared the threshold', () => { + storeMock.$detailData.set([CONTROL_ROW, { ...VARIANT_ROW, isWinner: false }]); + storeMock.$status.set(DotExperimentStatus.ENDED); + spectator.detectChanges(); + clickPromote(); + + expect(acceptConfirmation().message).toBe(BELOW_THRESHOLD_COPY); + }); + + it('chips the promoted variant and offers Promote nowhere once one has been promoted', () => { + storeMock.$promotedVariant.set(PROMOTED_VARIANT); + storeMock.$detailData.set([CONTROL_ROW, { ...VARIANT_ROW, isPromoted: true }]); + spectator.detectChanges(); + + expect(spectator.queryAll(byTestId('summary-row-promoted-chip')).length).toBe(1); + expect(spectator.queryAll(byTestId('summary-row-promote-btn')).length).toBe(0); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component.ts new file mode 100644 index 000000000000..261b2a32f818 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component.ts @@ -0,0 +1,152 @@ +import { injectDispatch } from '@ngrx/signals/events'; + +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; + +import { ConfirmationService } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { TagModule } from 'primeng/tag'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DEFAULT_VARIANT_ID, DotExperimentStatus } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotExperimentResultVariantDetail, LiftTone } from '../../../shared/models'; +import { DotExperimentsDetailsTableComponent } from '../../../shared/ui/dot-experiments-details-table/dot-experiments-details-table.component'; +import { dotExperimentsResultsPageEvents } from '../../../store/dot-experiments-results-page.events'; +import { DotExperimentsResultsStore } from '../../../store/dot-experiments-results.store'; + +/** + * Row dot colours, by row position. The control is always drawn first, so it always reads in the + * first colour — the same one the charts give it. Beyond the fifth variant the palette repeats. + */ +const VARIANT_COLORS: readonly string[] = ['#0ea5e9', '#a855f7', '#fb923c', '#22c55e', '#f43f5e']; + +/** How a Lift vs Original reads: nothing to compare against, a gain, or a loss (AC16). */ +const LIFT_TONE_CLASSES: Record = { + neutral: 'text-surface-400', + positive: 'text-green-800', + negative: 'text-red-700' +}; + +/** + * Promote confirm copy. The first sentence says whether the result is worth promoting, and the + * second is appended only while the experiment is still RUNNING, because promoting one ends it + * (AC19). + */ +const PROMOTE_CONFIRM_KEYS = { + header: 'experiments.results.promote.confirm.header', + aboveThreshold: 'experiments.results.promote.confirm.above-threshold', + belowThreshold: 'experiments.results.promote.confirm.below-threshold', + endsExperiment: 'experiments.results.promote.confirm.ends-experiment' +} as const; + +/** A summary-table row, plus everything the template would otherwise have to derive per row. */ +export interface DotExperimentsSummaryTableRow extends DotExperimentResultVariantDetail { + /** Colour of the row's dot, matching the variant's chart series. */ + color: string; + /** True for the `DEFAULT` variant, which is never promoted and has no lift of its own. */ + isControl: boolean; + /** Text colour the Lift vs Original is rendered in, resolved from its tone. */ + liftClass: string; +} + +/** + * Summary table of the Results screen: one row per variant of the primary goal. + * + * It renders under both chart tabs and reads everything from `DotExperimentsResultsStore`, so the + * shell places it without wiring anything through. The table itself is the shared + * `dot-experiments-details-table` shell unchanged — Lift vs Original and the Promoted chip are + * columns and cells added on top of it, not a table of its own (AC29). + * + * The gate on the data is experiment-wide: below the session threshold the whole table is replaced + * by one empty state, and above it every row shows its full data however few sessions it saw. No + * row is ever filtered out on its own count (AC15). + * + * The Promote confirmation lives here, next to the button that opens it, and is answered by + * dispatching `promoteRequested`: promoting a RUNNING experiment ends it in the same backend call, + * so the confirm says so beforehand and nothing else is dispatched afterwards (AC19/AC20). + */ +@Component({ + selector: 'dot-experiments-results-summary-table', + imports: [ + ButtonModule, + ConfirmDialogModule, + TagModule, + DotMessagePipe, + DotExperimentsDetailsTableComponent + ], + templateUrl: './dot-experiments-results-summary-table.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + // Its own instance, so this confirmation and the shell's Stop confirmation never answer for + // each other — they are two dialogs of the same kind, opened from two different places (AC21). + providers: [ConfirmationService] +}) +export class DotExperimentsResultsSummaryTableComponent { + readonly store = inject(DotExperimentsResultsStore); + + /** + * Rows as they are drawn: the control first, then the variants in the order the results name + * them. `$detailData` arrives in `Object.values()` order, which guarantees nothing, and the + * sort is presentation only — every row's lift is measured against the control by key, + * whichever position it happens to arrive in. + */ + readonly $rows = computed(() => + [...this.store.$detailData()] + .sort( + (first, second) => + Number(second.id === DEFAULT_VARIANT_ID) - + Number(first.id === DEFAULT_VARIANT_ID) + ) + .map((row, index) => ({ + ...row, + color: VARIANT_COLORS[index % VARIANT_COLORS.length], + isControl: row.id === DEFAULT_VARIANT_ID, + liftClass: LIFT_TONE_CLASSES[row.liftTone] + })) + ); + + /** One promotion is all there is: once any variant has been promoted, no row offers it (AC17). */ + readonly $canPromote = computed(() => !this.store.$promotedVariant()); + + readonly #dispatch = injectDispatch(dotExperimentsResultsPageEvents); + readonly #confirmationService = inject(ConfirmationService); + readonly #dotMessageService = inject(DotMessageService); + + /** + * Asks before promoting, and says what promoting will cost: while the experiment is RUNNING the + * same call ends it, which the copy states outright and omits once it has already ended (AC19). + * + * @param row - The row whose Promote button was pressed + */ + promoteVariant(row: DotExperimentsSummaryTableRow): void { + this.#confirmationService.confirm({ + header: this.#dotMessageService.get(PROMOTE_CONFIRM_KEYS.header), + message: this.#buildConfirmMessage(row), + acceptLabel: this.#dotMessageService.get('experiments.reports.promote'), + rejectLabel: this.#dotMessageService.get('dot.common.dialog.reject'), + rejectButtonStyleClass: 'p-button-secondary', + defaultFocus: 'reject', + closable: true, + closeOnEscape: true, + accept: () => this.#dispatch.promoteRequested(row.id) + }); + } + + /** + * Whether the row clears the significance threshold is the backend's call, not a comparison of + * rendered percentages: `isWinner` is the suggested winner it named, and only it applies the + * threshold. + */ + #buildConfirmMessage(row: DotExperimentsSummaryTableRow): string { + const threshold = this.#dotMessageService.get( + row.isWinner ? PROMOTE_CONFIRM_KEYS.aboveThreshold : PROMOTE_CONFIRM_KEYS.belowThreshold + ); + + if (this.store.$status() !== DotExperimentStatus.RUNNING) { + return threshold; + } + + return `${threshold} ${this.#dotMessageService.get(PROMOTE_CONFIRM_KEYS.endsExperiment)}`; + } +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/dot-experiments-results.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/dot-experiments-results.component.html new file mode 100644 index 000000000000..306e87218637 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/dot-experiments-results.component.html @@ -0,0 +1,91 @@ +@if ($isMisconfigured()) { +
+ +
+} @else if (store.$hasLoadError()) { +
+ +
+} @else if ($isLoading()) { +
+
+ +
+ + +
+
+ +
+
+ + + +
+
+
+} @else { + + + @if (store.lastRefreshFailed()) { +
+ + + {{ + (store.results() + ? 'experiments.results.refresh.failed' + : 'experiments.results.unavailable' + ) | dm + }} + +
+ } + +
+
+ + + +
+
+ + +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/dot-experiments-results.component.scss b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/dot-experiments-results.component.scss new file mode 100644 index 000000000000..fd1f20513366 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/dot-experiments-results.component.scss @@ -0,0 +1,27 @@ +/** + * Same short opacity-only fade the Configure screen enters with, so arriving at a report from the + * list or from Configure reads as navigation rather than as a flicker. Deliberately no translate: + * any movement on the host drags the fixed header along with it. + */ +// No display here: the host's flex-column layout comes from its host classes, and a component +// style would win over those utilities and break the body's bounded-height scroll. +:host { + animation: dot-experiments-screen-enter 180ms ease-out; +} + +@keyframes dot-experiments-screen-enter { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +// Motion is decoration here, so it is the first thing to drop for anyone who asked for less. +@media (prefers-reduced-motion: reduce) { + :host { + animation: none; + } +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/dot-experiments-results.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/dot-experiments-results.component.ts new file mode 100644 index 000000000000..0563f01d4e35 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/dot-experiments-results.component.ts @@ -0,0 +1,267 @@ +import { Events, injectDispatch } from '@ngrx/signals/events'; + +import { Component, computed, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { ConfirmationService } from 'primeng/api'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { SkeletonModule } from 'primeng/skeleton'; + +import { map } from 'rxjs/operators'; + +import { DotMessageDisplayService, DotMessageService } from '@dotcms/data-access'; +import { + ComponentStatus, + DotExperiment, + DotExperimentStatus, + DotMessageSeverity, + DotMessageType, + HealthStatusTypes +} from '@dotcms/dotcms-models'; +import { DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } from '@dotcms/ui'; + +import { DotExperimentsResultsChartsComponent } from './components/dot-experiments-results-charts/dot-experiments-results-charts.component'; +import { DotExperimentsResultsHeaderComponent } from './components/dot-experiments-results-header/dot-experiments-results-header.component'; +import { DotExperimentsResultsStatStripComponent } from './components/dot-experiments-results-stat-strip/dot-experiments-results-stat-strip.component'; +import { DotExperimentsResultsSummaryTableComponent } from './components/dot-experiments-results-summary-table/dot-experiments-results-summary-table.component'; + +import { + EXPERIMENTS_URL, + RESULTS_CONFIRM_DIALOG_KEY, + SUCCESS_MESSAGE_LIFE +} from '../shared/constants'; +import { dotExperimentsResultsApiEvents } from '../store/dot-experiments-results-api.events'; +import { dotExperimentsResultsPageEvents } from '../store/dot-experiments-results-page.events'; +import { DotExperimentsResultsStore } from '../store/dot-experiments-results.store'; + +/** Route `data` key `dotAnalyticsHealthCheckResolver` publishes the analytics health under. */ +const HEALTH_STATUS_ROUTE_DATA_KEY = 'healthStatus'; + +/** Route parameter naming the experiment being reported on. */ +const EXPERIMENT_ID_ROUTE_PARAM = 'experimentId'; + +/** + * Shell of the Results screen, routed on `/experiments/:experimentId/results`. + * + * It owns the fixed-height layout the report sits in — a header that stays put over a scrolling + * body — and everything that is screen-wide rather than panel-wide: which of the four states the + * screen is in, the Stop confirmation's dialog, and the toasts that follow a mutation. + * + * The four states are deliberately exclusive, in this order: a misconfigured analytics app, which + * takes out this screen and only this screen (AC22); a *first* load that failed, which is the one + * failure with nothing to show behind it and therefore the only one that blanks the screen + * (AC24); the first load itself, drawn as a skeleton of the report to come (AC23); and the report. + * A *refresh* that failed is none of them — the last good results stay exactly where they are and + * the screen says so in a banner over them (AC25). + * + * Which experiment to show is not read here: the store follows the route itself, so the shell only + * provides it. `DotExperimentsService` is not provided either — the route provides it for the + * health resolver, and the route injector is this component's parent. + */ +@Component({ + selector: 'dot-experiments-results', + imports: [ + ConfirmDialogModule, + SkeletonModule, + DotEmptyContainerComponent, + DotMessagePipe, + DotExperimentsResultsHeaderComponent, + DotExperimentsResultsStatStripComponent, + DotExperimentsResultsChartsComponent, + DotExperimentsResultsSummaryTableComponent + ], + templateUrl: './dot-experiments-results.component.html', + styleUrl: './dot-experiments-results.component.scss', + providers: [DotExperimentsResultsStore, ConfirmationService], + host: { class: 'flex flex-col h-full min-h-0 overflow-hidden' } +}) +export class DotExperimentsResultsComponent { + readonly store = inject(DotExperimentsResultsStore); + + readonly CONFIRM_KEY = RESULTS_CONFIRM_DIALOG_KEY; + + readonly #route = inject(ActivatedRoute); + readonly #router = inject(Router); + readonly #events = inject(Events); + readonly #dispatch = injectDispatch(dotExperimentsResultsPageEvents); + readonly #destroyRef = inject(DestroyRef); + readonly #dotMessageService = inject(DotMessageService); + readonly #dotMessageDisplayService = inject(DotMessageDisplayService); + readonly #confirmationService = inject(ConfirmationService); + + /** + * Analytics health, as the route resolved it. + * + * Followed rather than read once from the snapshot, for the same reason the store follows + * `paramMap`: the component is reused across experiments, and the resolver runs again on each + * of them. + */ + readonly #healthStatus = toSignal( + this.#route.data.pipe( + map((data) => data[HEALTH_STATUS_ROUTE_DATA_KEY] as HealthStatusTypes | undefined) + ) + ); + + /** Anything but `OK` means the report cannot be trusted, so none of it is shown (AC22). */ + readonly $isMisconfigured = computed(() => { + const healthStatus = this.#healthStatus(); + + return !!healthStatus && healthStatus !== HealthStatusTypes.OK; + }); + + /** + * The first load is still out. `INIT` counts: the store reads the route in its `onInit`, so the + * screen spends a tick there before the load starts, and treating it as loaded would flash an + * empty report first. + */ + readonly $isLoading = computed(() => { + const status = this.store.status(); + + return status === ComponentStatus.INIT || status === ComponentStatus.LOADING; + }); + + /** + * Copy shown instead of the report when Analytics is not usable. Mirrors the list screen's + * inline state: only `NOT_CONFIGURED` means "never set up", every other non-OK status is a + * broken configuration. + */ + readonly $misconfiguredConfiguration = computed(() => { + const isNotConfigured = this.#healthStatus() === HealthStatusTypes.NOT_CONFIGURED; + + return { + title: this.#dotMessageService.get( + isNotConfigured + ? 'experiments.analytics-app-no-configured.title' + : 'experiments.analytics-app-misconfiguration.title' + ), + subtitle: this.#dotMessageService.get( + isNotConfigured + ? 'experiments.analytics-app-no-configured.subtitle' + : 'experiments.analytics-app-misconfiguration.subtitle' + ), + icon: 'analytics', + iconStyle: 'material-symbols-rounded' + }; + }); + + /** + * Shown when nothing could be loaded. The error itself is already surfaced by + * `DotHttpErrorManagerService`; this is the screen's own state, so a failed load reads as a + * failure with a way out rather than as an empty report (AC24). + */ + readonly errorConfiguration: PrincipalConfiguration = { + title: this.#dotMessageService.get('experiments.results.error.title'), + subtitle: this.#dotMessageService.get('experiments.error.fetching.data'), + icon: 'error', + iconStyle: 'material-symbols-rounded' + }; + + constructor() { + this.#listenForActionSuccess(); + } + + /** Leaves the Results screen for the list. */ + onBackToList(): void { + this.#router.navigate([EXPERIMENTS_URL]); + } + + /** + * Runs the whole load again, experiment included. + * + * Not a refresh: a first load that failed left no experiment behind, and the refresh handler + * has nothing to re-fetch results for. + */ + onRetry(): void { + const experimentId = this.#route.snapshot.paramMap.get(EXPERIMENT_ID_ROUTE_PARAM); + + if (experimentId) { + this.#dispatch.enter(experimentId); + } + } + + /** + * Re-fetches the report only. The stat strip raises this; the shell relays it because the + * strip is presentational and holds no dispatcher of its own. + */ + onRefresh(): void { + this.#dispatch.refreshRequested(); + } + + /** + * Promotes the variant the stat strip offers inline when the leader is not the control (AC7). + * + * Asks first, exactly as the per-row Promote in the summary table does: the strip's button is a + * shortcut to the same irreversible action, so it cannot be the one path that skips the + * confirmation (AC19/AC21). The strip is presentational and raises the intent; the decision and + * the dialog live here, on the component that owns the keyed `p-confirmDialog`. + * + * The leader the strip offers is the backend's suggested winner, which is what clears the + * threshold — hence the above-threshold copy. While the experiment is RUNNING the same call + * ends it, which the copy states outright. + * + * @param variantId - Id of the variant to promote + */ + onPromote(variantId: string): void { + const endsExperiment = this.store.$status() === DotExperimentStatus.RUNNING; + const threshold = this.#dotMessageService.get( + 'experiments.results.promote.confirm.above-threshold' + ); + + this.#confirmationService.confirm({ + key: RESULTS_CONFIRM_DIALOG_KEY, + header: this.#dotMessageService.get('experiments.results.promote.confirm.header'), + message: endsExperiment + ? `${threshold} ${this.#dotMessageService.get('experiments.results.promote.confirm.ends-experiment')}` + : threshold, + acceptLabel: this.#dotMessageService.get('experiments.reports.promote'), + rejectLabel: this.#dotMessageService.get('dot.common.dialog.reject'), + rejectButtonStyleClass: 'p-button-secondary', + defaultFocus: 'reject', + closable: true, + closeOnEscape: true, + accept: () => this.#dispatch.promoteRequested(variantId) + }); + } + + /** + * The store persists and reloads on its own; the toast is a UI concern and therefore lives + * here. Only the outcomes the user asked for get one — a failed call is already reported by + * `DotHttpErrorManagerService` inside the store. + * + * Promoting a RUNNING experiment ends it in the same call, which the confirmation warns about + * beforehand, so the success is one toast and not two. + */ + #listenForActionSuccess(): void { + this.#events + .on(dotExperimentsResultsApiEvents.stopSucceeded) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe(({ payload }) => + this.#pushSuccess('experiments.action.stop.confirm-message', payload.name) + ); + + this.#events + .on(dotExperimentsResultsApiEvents.promoteSucceeded) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe(({ payload }) => + this.#pushSuccess( + 'experiments.action.promote.variant.confirm-message', + this.#promotedVariantNameOf(payload) + ) + ); + } + + /** The variant the answered experiment now carries as promoted, which the toast names. */ + #promotedVariantNameOf({ trafficProportion }: DotExperiment): string { + return trafficProportion?.variants.find(({ promoted }) => promoted)?.name ?? ''; + } + + #pushSuccess(messageKey: string, argument: string): void { + this.#dotMessageDisplayService.push({ + life: SUCCESS_MESSAGE_LIFE, + severity: DotMessageSeverity.SUCCESS, + message: this.#dotMessageService.get(messageKey, argument), + type: DotMessageType.SIMPLE_MESSAGE + }); + } +} diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index f2c2111fe892..642b247ec285 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6011,6 +6011,7 @@ experiments.list.filter.status=Status experiments.list.filter.goal=Goal experiments.list.filter.all=All experiments.list.actions.menu=Experiment actions +experiments.list.actions.view-results=View Results experiments.list.header.experiment=Experiment experiments.list.header.page=Page experiments.list.header.goal=Goal @@ -6105,6 +6106,18 @@ experiments.configure.scheduling.end.error.out-of-bounds=The end date must be be experiments.configure.scheduling.note.scheduled=The Experiment starts automatically on {0}. experiments.configure.scheduling.note.immediate=With no dates set, Start Experiment begins collecting sessions immediately and runs until stopped. experiments.configure.scheduling.action.clear=Clear Schedule +experiments.results.stat-strip.winner=Winner +experiments.results.stat-strip.leading-variant=Leading Variant +experiments.results.stat-strip.period=Period +experiments.results.stat-strip.threshold-met=The result clears the 95% threshold. Promote this Variant to make it the published Page content. +experiments.results.stat-strip.threshold-not-met=Below the 95% threshold – keep the Experiment running before promoting a Variant. + +# Experiments portlet - results screen +experiments.results.header.variants={0} Variants +experiments.results.stop.confirm-message=Ending the Experiment stops collecting data now. The results collected so far stay available. +experiments.results.error.title=Could not load results +experiments.results.refresh.failed=The results could not be refreshed. The last results loaded are still shown. +experiments.results.unavailable=The results could not be loaded. Everything else about this Experiment is shown below. seo.rules.read-more.title=Read More @@ -7914,3 +7927,11 @@ dot.asset.picker.error.assets=Couldn't load the assets dot.asset.picker.error.folders=Couldn't load the folders dot.asset.picker.confirm.error=Couldn't add the asset dot.asset.picker.confirm.error.detail=We couldn't load the selected asset. It may have been deleted or you may no longer have access to it. +# Experiments portlet - results screen, summary table +experiments.results.summary.column.lift=Lift vs Original +experiments.results.summary.chip.leading=LEADING +experiments.results.summary.chip.promoted=Promoted +experiments.results.promote.confirm.header=Promote Variant +experiments.results.promote.confirm.above-threshold=The result clears the 95% threshold. Promote this Variant to make it the published Page content. +experiments.results.promote.confirm.below-threshold=Below the 95% threshold – keep the Experiment running before promoting a Variant. +experiments.results.promote.confirm.ends-experiment=Promoting now ends the Experiment automatically. From 52d89e58f312083827e7ff6a1ae97533e922cb52 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Thu, 20 Aug 2026 11:04:29 -0400 Subject: [PATCH 5/5] feat(experiments): View Results opens the report from the list Leads the row's action cell, with Configure staying the first kebab entry, as the design lays it out. Ungated on purpose, unlike every kebab entry: AllowedActionsByExperimentStatus clears RUNNING and ENDED only, but the report renders a waiting state of its own for an experiment with nothing counted yet, so the row offers it on every status. Also drops the module-local configureCommandsOf for the shared helper. --- .../dot-experiments-list.component.html | 10 +++++--- .../dot-experiments-list.component.ts | 24 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.html index 3e1fd196e8d6..c6a78423165d 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.html @@ -203,11 +203,15 @@ {{ row.experiment.modDate | date: 'MMM d, y' }} -
+ [ +/** Results URL of an experiment. */ +const resultsCommandsOf = (experimentId: string): string[] => [ EXPERIMENTS_URL, experimentId, - CONFIGURATION_SEGMENT + RESULTS_SEGMENT ]; @Component({ @@ -430,6 +431,17 @@ export class DotExperimentsListComponent { this.#router.navigate(configureCommandsOf(experiment.id)); } + /** + * Opens the Results screen of an experiment. + * + * Ungated on purpose, unlike every kebab entry: `AllowedActionsByExperimentStatus.results` + * clears RUNNING and ENDED only, but the screen renders a waiting state of its own for an + * experiment with nothing to count yet, so the row leads with it whatever the status (AC6). + */ + onViewResults(experiment: DotExperiment): void { + this.#router.navigate(resultsCommandsOf(experiment.id)); + } + confirmArchive(experiment: DotExperiment): void { this.#confirm({ headerKey: 'experiments.action.archive', @@ -444,8 +456,8 @@ export class DotExperimentsListComponent { return [ { - // The primary action of the row: it leads the menu, and is the only entry every - // status allows. + // Leads the menu, behind the row's own View Results control: it is the only + // entry every status allows. id: 'experiments-configure', label: this.#dotMessageService.get('experiments.list.action.configure'), visible: isAllowed('configuration', status),