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/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),
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 @@
+
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 @@
+
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 @@
+
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 @@
+
+
+
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()) {
+
+
+
+}
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/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/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/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/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/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/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',
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-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];
+}
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;
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.