Skip to content

feat(experiments): view results screen (/experiments/:id/results) - #37135

Open
oidacra wants to merge 7 commits into
mainfrom
issue-37004-experiments-results-screen
Open

feat(experiments): view results screen (/experiments/:id/results)#37135
oidacra wants to merge 7 commits into
mainfrom
issue-37004-experiments-results-screen

Conversation

@oidacra

@oidacra oidacra commented Aug 20, 2026

Copy link
Copy Markdown
Member

What

Screen 3 of the Experiments portlet: the Results screen on /experiments/:experimentId/results — header, stat strip, Daily/Bayesian tabs and summary table, with promote and stop.

Closes #37004

Stacked on #37064. Base is oidacra/experiments-portlet-screen-2-create-update-exper, so this diff shows only screen 3. Retarget to main after #37064 merges.

Reuse over reimplementation

The charts, the legend plugin, the headless table and the winner/promoted helpers already existed under old/, which is frozen and slated for deletion. Rather than duplicating them, they are relocated to shared/ and old/ is repointed at the new paths — one source of truth, and old/ stays deletable. Only import lines change under old/; git records the moves as R100/R099/R097, and old/'s specs pass unmodified.

The approved prototype drives visual and behavioural detail only.

Notable decisions

  • The load is sequential, not forkJoin. DRAFT and SCHEDULED settle on getById alone and never reach getResults, which is uncached and costs two analytics round-trips plus a Monte Carlo run. The branch reads experiment.status, not whether results is null.
  • A lost report does not cost the screen. When getById succeeds and getResults fails, the header, goal and schedule still render and the failure is reported inline. The full error card is reserved for a missing experiment. Blanking here regressed against the screen this replaces — reproduced against a CubeJS schema without the Events cube, where /results 400s while getById returns 200.
  • The winner comes from the backend. The prototype picks the leader by highest conversion rate with no significance gate, so it always names one. Using bayesianResult.suggestedWinner is what lets the "no winner yet" state exist at all.
  • Exactly one chart is mounted at a time. The legend plugin resolves its container by walking up from the canvas and calling querySelector, which from a shared ancestor returns the first match in document order — two mounted charts would silently claim each other's legend, with no thrown error. @if/@else makes that impossible.
  • The session gate is experiment-wide. Below ten total sessions the whole summary is one empty state; above it every row shows full data regardless of its own count.
  • Both Promote entry points confirm. Promoting a RUNNING experiment ends it server-side in the same call (ExperimentsAPIImpl.java:1409), so the strip's inline shortcut cannot be the one path that skips the dialog.
  • Lift is keyed, not positional. The baseline is resolved by key and isControl by name, so row order cannot change the arithmetic.

Verification

Check Result
nx test portlets-dot-experiments-portlet 52/52 suites, 914/914 tests
tsc --noEmit 0 errors in the portlet
nx affected -t lint pass
nx format:check clean

Four projects (dotcms-js, block-editor, dotcms-block-editor, dotcms-webcomponents) fail nx affected -t test for reasons unrelated to this branch: the only dotcms-models change here is 8 purely additive lines, block-editor has no dependency on experiments models, and dotcms-js fails on Cannot find module 'karma'.

Exercised in the browser on the routed screen: header, stat strip, tabs and summary render; tabs switch with a single legend container; the negative winner state renders; goal and period read off the experiment when the report is missing.

Acceptance criteria

  • Route renders stat strip, tabs and summary for RUNNING and ENDED
  • Daily and Bayesian charts use the existing Chart.js config; legend toggles series
  • Lift vs Original in points vs the control, green/red, em dash on control and on a zero-rate control
  • Experiment-wide 10-session gate replaces the whole summary
  • Refresh re-fetches without a full navigation
  • No suggested winner renders the negative state, not a false Leading Variant
  • Promote confirms, states the auto-end while RUNNING, then hides every Promote and chips the promoted row
  • Stop Experiment confirms and re-renders the header as ENDED in place
  • A misconfigured analytics app blocks only this route
  • The old UVE reports screen is untouched
  • Jest specs for the results store and the summary table

Not in this PR

  • The htmlLegendPlugin could not be exercised end to end: without results data there is no canvas. Needs an environment with analytics data, or Experiments: A/B Testing v2 #36763's migration.
  • New Language.properties keys need a dotCMS restart to load; the raw key shows until then.
  • E2E (empty state, session gate, promote confirm) lives in the dedicated E2E issue.

@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @oidacra's task in 4m 41s —— View job


Review — PR #37135 (Experiments Results screen)

Reviewed the results store, events, shell, header, stat-strip, summary table, charts, the relocated utils, routing, models, and the new i18n keys. Verified all referenced message keys resolve (new ones added; the 7 pre-existing ones exist). One finding.

New Issues

  • 🟠 High: store/dot-experiments-results.store.ts:342 (with :313) — A misconfigured analytics app still fires getResults, popping a modal error dialog over the misconfiguration empty state (regresses AC22). The store's onInit subscribes to the route param and dispatches enter unconditionally — it has no knowledge of healthStatus, which lives only in the component (dot-experiments-results.component.ts:100-111). When analytics is misconfigured the component renders the $isMisconfigured empty container, but the store independently loads: getById succeeds and getResults 400s (the exact repro described in the PR body — CubeJS schema without the Events cube). That rejection flows through toResultsFailurehttpErrorManager.handle(error) with the default unobtrusive = false, and for a 400 DotHttpErrorManagerService.handleBadRequestError calls dotDialogService.alert(...) — a modal alert dialog (dot-http-error-manager.service.ts:135-149,176-190). Net effect on a misconfigured app: the intended "blocks only this route" empty state plus an unexpected modal error on top, and the expensive getResults call fires for a screen that will never show its result.

    What to verify: load the routed screen against an analytics config that is not OK and confirm no HTTP error dialog appears over the misconfiguration container. Fix options: gate the load$ dispatch (or the getResults leg) on the resolved health status, or have the shell not instantiate/enter the store while $isMisconfigured().

    Fix this →

Everything else looks solid: the sequential DRAFT/SCHEDULED short-circuit, the keyed-vs-unkeyed confirm-dialog separation, the single-mounted-chart @if/@else, lift keyed by DEFAULT_VARIANT_ID, the em-dash guards on the control and zero-rate control, and the Lucene page lookup guarded by isDotIdentifier are all correct. No SQL/permission/secret concerns (frontend-only), and the [innerHTML] winner legend is Angular-sanitized.

  • Read store, events, shell, header, stat-strip, summary, charts
  • Read utils, routes, models, constants, list changes
  • Verified i18n keys and error-handling path
  • Posted review
    · issue-37004-experiments-results-screen

@oidacra
oidacra force-pushed the issue-37004-experiments-results-screen branch from e2f3e2a to 52d89e5 Compare August 20, 2026 19:27
@github-actions github-actions Bot added Area : Frontend PR changes Angular/TypeScript frontend code and removed AI: Safe To Rollback labels Aug 20, 2026
@oidacra
oidacra force-pushed the issue-37004-experiments-results-screen branch 2 times, most recently from 523ebf6 to 03f1e9f Compare August 24, 2026 19:55
@oidacra
oidacra force-pushed the issue-37004-experiments-results-screen branch from 53b1086 to 3b79943 Compare August 28, 2026 19:28
@oidacra
oidacra force-pushed the issue-37004-experiments-results-screen branch from 3b79943 to 2cbdc0f Compare August 28, 2026 21:25
Base automatically changed from oidacra/experiments-portlet-screen-2-create-update-exper to main August 31, 2026 14:58
@oidacra
oidacra force-pushed the issue-37004-experiments-results-screen branch from 2cbdc0f to bb2df76 Compare August 31, 2026 14:58
@oidacra
oidacra force-pushed the issue-37004-experiments-results-screen branch from bb2df76 to d3a772c Compare August 31, 2026 16:53
The Results screen needs the Chart.js options, the interactive legend plugin, the
p-chart wrapper, the headless details table and the winner/promoted/chart-data
helpers. All of them live under old/, which is frozen and slated for deletion, so
they are relocated to shared/ and old/ is repointed at the new paths rather than
duplicating them: one source of truth, and old/ stays deletable.

Only import lines change under old/, so its behaviour is untouched and its specs
still pass unmodified. Three importers the plan had not accounted for turned up
in the Configure legacy screen and needed the same repoint.

getConversionRate, getConversionRateRage, getBayesianVariantResult and
getProbabilityToBeBest stay where they are: they are not needed outside old/, and
the dependency direction has to remain old/ -> shared/, never the reverse.
State is {experiment, results, status, refreshing, lastRefreshFailed}, keyed by
experimentId alone, so the screen is page-independent the way the old reports
store already was.

The load runs sequentially rather than as the old screen's forkJoin, because the
second call depends on the first: DRAFT and SCHEDULED settle on getById alone and
never reach getResults, which is uncached and costs two analytics round-trips plus
a Monte Carlo run. The branch reads the experiment's status, not whether results
happen to be null.

A failed refresh leaves results and status untouched, so the last good report
stays on screen and only a flag says the refresh failed. A first load that loses
only its report keeps the experiment and reports inline; blanking there would
regress against the screen this one replaces.

Lift vs Original is computed here since no field carries it. The baseline is
resolved by key and isControl by name, never by row position, so the order rows
arrive in cannot change the arithmetic.

The refresh flag is raised only for the statuses the handler will actually serve;
raising it for the others would spin forever with no request in flight.
Wires :experimentId/results, reachable on every status including DRAFT, and hangs
dotAnalyticsHealthCheckResolver off this route only so a broken analytics app takes
out the report and leaves the list reachable.

That resolver is a plain ResolveFn: it reports rather than redirects, so the screen
reads the resolved status and renders the misconfiguration state in place of the
report. The edit-page-coupled AnalyticsAppGuard is deliberately not reused.

DotExperimentsService is provided on the route rather than the component. The
resolver injects it and runs in the route injector, before the screen exists, so
providing it on the screen alone would still throw NG0201 on activation.

configureCommandsOf moves to the list util: the list's row action and the Results
header's Configuration button are two ways to the same URL, and a URL spelled out
twice is a URL that can drift.
Shell, header, stat strip, Daily/Bayesian tabs and summary table, per the approved
prototype.

Both charts reuse the relocated Chart.js options and legend plugin; the prototype's
inline SVG is not reimplemented and the Bayesian posterior comes entirely from the
backend. The two charts render in @if/@else so exactly one canvas is ever mounted:
the legend plugin resolves its container by walking up from the canvas and calling
querySelector, which from a shared ancestor returns the first match in document
order, so two mounted charts would silently claim each other's legend.

The stat strip is presentational and takes the winner legend from the backend's
suggested winner, never from a locally computed highest rate, so "no winner yet"
renders instead of a false Leading Variant claim. Goal and period come off the
experiment, so they still read when the report does not.

The session gate is experiment-wide: below ten total sessions the whole summary is
one empty state, with no per-row filtering above it.

Promote asks first from both entry points -- the strip's inline shortcut and the
table's per-row button -- since promoting a RUNNING experiment ends it server-side
in the same call. The table hosts its own dialog so the shell and the table never
share a key, which would open both at once.
Leads the row's action cell, with Configure staying the first kebab entry, as the
design lays it out.

Ungated on purpose, unlike every kebab entry: AllowedActionsByExperimentStatus
clears RUNNING and ENDED only, but the report renders a waiting state of its own
for an experiment with nothing counted yet, so the row offers it on every status.

Also drops the module-local configureCommandsOf for the shared helper.
…ns shed pill and icon

Design review on the running screen.

View Results leaves the row for the menu, where it leads: it is the only entry
every status allows, since the Results screen renders its own waiting state for
an experiment with nothing counted yet. This is a temporary home — the design
gives it the row's own control — so the reason is recorded next to it rather
than lost here.

Configuration, Stop Experiment and the row Promote drop the pill and the leading
material symbol, matching the split the Configure screen already settled on:
labelled buttons render plain, icon-only controls keep the pill. Back and the
refresh control are unchanged.
…he report

It shipped disabled behind a "coming soon" tooltip because the screen it points
at did not exist yet; it does now, so the TODO and the tooltip go with it.

`resultsCommandsOf` moves next to its Configure twin in the shared util: the
list's menu entry and this button are two ways to the same URL, and a URL spelled
out twice is a URL that can drift.

The button belongs in this branch rather than in the one that owns the file —
enabling it where the results route does not exist would link to a 404.

The two specs that pinned the disabled state now assert the navigation instead.
@oidacra
oidacra force-pushed the issue-37004-experiments-results-screen branch from d3a772c to 1f9eb41 Compare August 31, 2026 20:29
@oidacra
oidacra marked this pull request as ready for review August 31, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Experiments Portlet — Screen 3: View Results

1 participant