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 @@ +
+
+ @for (tab of tabs; track tab.id) { + + } +
+ + @if ($activeTab() === 'daily') { + + } @else { + + } +
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-charts/dot-experiments-results-charts.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-charts/dot-experiments-results-charts.component.ts new file mode 100644 index 000000000000..82313fefd9d1 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-charts/dot-experiments-results-charts.component.ts @@ -0,0 +1,79 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotExperimentsReportsChartComponent } from '../../../shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component'; +import { DotExperimentsResultsStore } from '../../../store/dot-experiments-results.store'; + +/** The two reports the Results screen charts, one per tab. */ +type ResultsChartTab = 'daily' | 'bayesian'; + +/** Axis labels the chart needs as plain strings, so they are resolved once per chart. */ +interface ChartAxisLabels { + xAxisLabel: string; + yAxisLabel: string; +} + +/** + * The charts half of the Results screen: Daily results and Bayesian results behind two tabs. + * + * Only the selected tab is rendered. The chart's legend is drawn by a Chart.js plugin that walks + * *up* from the canvas until it finds a `.legend-wrapper`, and that walk also inspects siblings — + * with both charts mounted at once, one could claim the other's wrapper and a legend would silently + * go missing. `@if` keeps exactly one canvas in the tree, so each chart can only ever find its own. + * + * For the same reason the chart component is composed as a plain child: nothing here wraps or + * re-projects its internals. + * + * Both charts are read-only views of the store — the Bayesian curves arrive already computed from + * the backend, and nothing is derived from them here. + */ +@Component({ + selector: 'dot-experiments-results-charts', + imports: [DotExperimentsReportsChartComponent, DotMessagePipe], + templateUrl: './dot-experiments-results-charts.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'block w-full' + } +}) +export class DotExperimentsResultsChartsComponent { + readonly #dotMessageService = inject(DotMessageService); + + protected readonly store = inject(DotExperimentsResultsStore); + + protected readonly $activeTab = signal('daily'); + + protected readonly tabs: readonly { id: ResultsChartTab; labelKey: string }[] = [ + { id: 'daily', labelKey: 'experiments.reports.chart.title' }, + { id: 'bayesian', labelKey: 'experiments.bayesian.reports.chart.title' } + ]; + + protected readonly dailyAxisLabels: ChartAxisLabels = { + xAxisLabel: this.#dotMessageService.get('experiments.chart.xAxisLabel'), + yAxisLabel: this.#dotMessageService.get('experiments.chart.yAxisLabel') + }; + + protected readonly bayesianAxisLabels: ChartAxisLabels = { + xAxisLabel: this.#dotMessageService.get('experiments.chart.xAxisLabel.bayesian'), + yAxisLabel: this.#dotMessageService.get('experiments.chart.yAxisLabel.bayesian') + }; + + /** + * A chart with too few sessions, or with no payload at all — DRAFT and SCHEDULED included, + * where no results are ever fetched — hands the empty state to the chart component rather than + * drawing an axis nothing sits on. + */ + protected readonly $isDailyEmpty = computed( + () => !this.store.$hasEnoughSessionsForDailyChart() || !this.store.$dailyChartData() + ); + + protected readonly $isBayesianEmpty = computed( + () => !this.store.$hasEnoughDataForBayesianChart() || !this.store.$bayesianChartData() + ); + + protected selectTab(tab: ResultsChartTab): void { + this.$activeTab.set(tab); + } +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-header/dot-experiments-results-header.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-header/dot-experiments-results-header.component.html new file mode 100644 index 000000000000..bdacc865a776 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-results/components/dot-experiments-results-header/dot-experiments-results-header.component.html @@ -0,0 +1,67 @@ +
+ + + + +
+
+

+ {{ $title() }} +

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