[Bug] Stopped experiments with no phase end date show "ended <today>" and break Date sorting
Summary
On /experiments#stopped, a stopped experiment whose last phase has no dateEnded renders as ended <today>, and that fabricated value is also the Date column's sort key — so those rows sort as if they ended today and the date changes every day. The experiment page shows the same gap as Runtime: <start> - now beside a "Stopped" badge.
Reported by an Enterprise Cloud customer with 148 stopped experiments, one of which stopped in March 2026 and was listed as ended Sep 9, 2026.
Reproduce
- Get an experiment to
status: "stopped"with the last phase'sdateEndedunset — e.g.POST /api/v1/experiments/:idwith{"status":"stopped"}, a Statsig import of anabandonedexperiment, or clearing the end date in the Edit Phase modal on an already-stopped experiment. - Open /experiments#stopped sorted by Date.
Expected: no date, sorted after experiments that have a real end date. Actual: ended <today>, sorted to the top, changing daily.
Cause 1 — the display substitutes the current time
packages/front-end/services/experiments.ts:172-182:
: exp.status === "stopped"
? exp.phases?.[exp.phases?.length - 1]?.dateEnded
: exp.dateCreated) ?? new Date().toISOString() // fallback to nowThat value is rendered with the word "ended" (components/Experiment/ExperimentsListTable.tsx:212-227) and is also the sort key (services/experiments.ts:439-441,501; comparator services/search.tsx:397-406), so the display and the sort share one fabricated timestamp. search.tsx:400-403 already has an undefinedLast branch that the ?? makes unreachable.
The experiment page handles the same missing field separately and never checks status: components/Experiment/TabbedPage/ProjectTagBar.tsx:87-89 prints the literal "now". components/Metrics/DateGraph.tsx:324-327 already does this correctly, gating its fallback on status === "running".
Cause 2 — stopped transitions that never write dateEnded
dateEnded is optional with no default (shared/src/validators/experiments.ts:101-105, back-end/src/models/ExperimentModel.ts:261-265). stopExperiment() (services/experimentChanges/changeExperimentStatus.ts:834-846 — Stop modal, /stop, scheduled auto-stop) and postExperimentStatus for running → stopped (controllers/experiments.ts:2439-2453) do close the phase. These don't:
- REST
POST /api/v1/experiments/:id, which writes only the status (services/experiments.ts:4847); the handler special-cases onlydraft → running. - Internal
POST /experiment/:id, where"phases"isn't in the writable key allowlist (controllers/experiments.ts:1875-1974). - Both create endpoints, which accept
status: "stopped"with an open phase. - The Statsig importer, where
decision_made/abandonedmap to stopped withdateEnded: endTime ? … : "". - The Edit Phase modal, whose "Leave blank if still running" and Clear Input (
EditPhaseModal.tsx:96-108→controllers/experiments.ts:2898-2903) wipe a correct date. - A GET→PATCH round-trip: the serializer emits
""(services/experiments.ts:3064) and the update maps it back toundefined(:4654).
No migration or backfill exists, and phase dates are excluded from the audit diff (models/ExperimentModel.ts:1320-1327), so these leave no trace.
"Awaiting decision" is a co-symptom
That label derives only from archived/status/type/results (shared/src/enterprise/decision-criteria/statusIndicatorData.ts:105-116), never dateEnded. The paths above skip results too, so the same records lack both.
When it started
#4342 (2025-07-28, v4.1.0) changed the fallback from ?? "" to ?? new Date().toISOString() while fixing this column's sort; before it, the cell was blank but still sorted as today. The experiment page's - now arrived with #4848 (2026-01-06, v4.3.0). "Awaiting decision" is older (#3323, #3709) and unrelated. #6856 and #6827 address the sibling missing-dateStarted problem and don't touch this.
Suggested fix
Display, shippable on its own: return undefined from experimentDate() for a stopped experiment with no dateEnded, render an explicit "no end date", pass undefinedLast so those rows sort last, and gate the runtime line on experiment.status.
Write side: close the last phase on every stopped transition, ideally by routing these paths through stopExperiment(), and stop the round-trip from clearing a set value.
Open question: backfill
The display fix leaves existing records with no date, and there's no true end date to recover — only proxies (the phase's last snapshot, an audit-log status change, dateUpdated). Suggestion: backfill only where an audit event gives a real timestamp and leave the rest blank and labelled.
Verified against main @ 4b52e5f (2026-09-09).
Source: growthbook/growthbook