diff --git a/resources/js/pages/experiments/Show.vue b/resources/js/pages/experiments/Show.vue
index 28973e1..d6b7dd0 100644
--- a/resources/js/pages/experiments/Show.vue
+++ b/resources/js/pages/experiments/Show.vue
@@ -63,7 +63,7 @@ const applyVariant = async (variant) => {
- Completed
+ {{ __('Completed') }}
@@ -216,7 +216,7 @@ const applyVariant = async (variant) => {
-
+
diff --git a/resources/views/widgets/ab-tester.blade.php b/resources/views/widgets/ab-tester.blade.php
new file mode 100644
index 0000000..c999c30
--- /dev/null
+++ b/resources/views/widgets/ab-tester.blade.php
@@ -0,0 +1,40 @@
+
+ @if ($experiments->isEmpty())
+
+
{{ __('No experiments are currently running.') }}
+
{{ __('Create an experiment') }} →
+
+ @else
+
+
+ {{ __('Experiment') }}
+ {{ __('Hits') }}
+ {{ __('Leader') }}
+
+
+ @foreach ($experiments as $exp)
+
+
+ {{ $exp['title'] }}
+
+ {{ number_format($exp['total_hits']) }}
+
+ @if ($exp['leader_label'] !== null)
+ {{ $exp['leader_label'] }}
+ {{ $exp['leader_rate'] }}%
+ @else
+ —
+ @endif
+
+
+ @endforeach
+
+
+
+
+
+
+ @endif
+
diff --git a/src/Widgets/ABTesterWidget.php b/src/Widgets/ABTesterWidget.php
new file mode 100644
index 0000000..34f60d0
--- /dev/null
+++ b/src/Widgets/ABTesterWidget.php
@@ -0,0 +1,57 @@
+whereNull('completed_at')
+ ->where('published', true)
+ ->where(fn ($q) => $q->whereNull('start_at')->orWhere('start_at', '<=', now()))
+ ->where(fn ($q) => $q->whereNull('end_at')->orWhere('end_at', '>=', now()))
+ ->get();
+
+ $experiments = $activeExperiments->map(function ($experiment) {
+ $variantResults = $experiment->resultsQuery()
+ ->select('variation', DB::raw('count(*) as total'))
+ ->groupBy('variation')
+ ->get()
+ ->map(function ($row) use ($experiment) {
+ $successes = $experiment->resultsQuery()
+ ->where('variation', $row->variation)
+ ->where('type', 'success')
+ ->count();
+
+ return [
+ 'label' => $row->variation,
+ 'hits' => $row->total,
+ 'rate' => $row->total > 0 ? round($successes / $row->total * 100, 1) : 0,
+ ];
+ });
+
+ $leader = $variantResults->sortByDesc('rate')->first();
+
+ return [
+ 'title' => $experiment->title(),
+ 'url' => cp_route('ab.experiments.show', $experiment->id()),
+ 'total_hits' => $variantResults->sum('hits'),
+ 'leader_label' => $leader ? $leader['label'] : null,
+ 'leader_rate' => $leader ? $leader['rate'] : null,
+ ];
+ });
+
+ return view('ab::widgets.ab-tester', [
+ 'activeCount' => $activeExperiments->count(),
+ 'experiments' => $experiments,
+ 'indexUrl' => cp_route('ab.experiments.index'),
+ ])->render();
+ }
+}
diff --git a/tests/Widgets/ABTesterWidgetTest.php b/tests/Widgets/ABTesterWidgetTest.php
new file mode 100644
index 0000000..2d3b425
--- /dev/null
+++ b/tests/Widgets/ABTesterWidgetTest.php
@@ -0,0 +1,131 @@
+setConfig([]);
+
+ return $widget;
+}
+
+describe('ABTesterWidget', function () {
+ it('is registered with the correct handle', function () {
+ expect(ABTesterWidget::handle())->toBe('ab_tester');
+ });
+
+ it('renders HTML', function () {
+ $html = makeWidget()->html();
+
+ expect($html)->toBeString()->not->toBeEmpty();
+ });
+
+ it('shows a count of zero when there are no active experiments', function () {
+ $html = makeWidget()->html();
+
+ expect($html)->toContain('0');
+ expect($html)->toContain('No experiments are currently running.');
+ });
+
+ it('counts active experiments', function () {
+ tap(Experiment::make('active-1')->title('Active One')->published(true))->save();
+ tap(Experiment::make('active-2')->title('Active Two')->published(true))->save();
+
+ $html = makeWidget()->html();
+
+ expect($html)->toContain('2');
+ expect($html)->toContain('Active One');
+ expect($html)->toContain('Active Two');
+ });
+
+ it('excludes completed experiments', function () {
+ tap(Experiment::make('active')->title('Active')->published(true))->save();
+
+ tap(Experiment::make('done')->title('Done')->published(true))
+ ->completedAt(now())
+ ->save();
+
+ $html = makeWidget()->html();
+
+ expect($html)->toContain('Active');
+ expect($html)->not->toContain('Done');
+ });
+
+ it('excludes unpublished experiments', function () {
+ tap(Experiment::make('published')->title('Published')->published(true))->save();
+ tap(Experiment::make('draft')->title('Draft')->published(false))->save();
+
+ $html = makeWidget()->html();
+
+ expect($html)->toContain('Published');
+ expect($html)->not->toContain('Draft');
+ });
+
+ it('excludes experiments that have not started yet', function () {
+ tap(Experiment::make('future')->title('Future')->published(true))
+ ->startAt(now()->addDay())
+ ->save();
+
+ $html = makeWidget()->html();
+
+ expect($html)->not->toContain('Future');
+ });
+
+ it('excludes experiments past their end date', function () {
+ tap(Experiment::make('expired')->title('Expired')->published(true))
+ ->endAt(now()->subDay())
+ ->save();
+
+ $html = makeWidget()->html();
+
+ expect($html)->not->toContain('Expired');
+ });
+
+ it('shows total hits for each experiment', function () {
+ $experiment = tap(Experiment::make('hit-test')->title('Hit Test')->published(true))->save();
+
+ AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => '1', 'type' => 'hit', 'data' => []]);
+ AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => '1', 'type' => 'hit', 'data' => []]);
+ AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => '2', 'type' => 'hit', 'data' => []]);
+
+ $html = makeWidget()->html();
+
+ expect($html)->toContain('3');
+ });
+
+ it('shows the leading variant label', function () {
+ $experiment = tap(Experiment::make('leader-test')->title('Leader Test')->published(true))->save();
+
+ // variant 1: 1 hit, 1 success = 100% rate
+ AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => 'control', 'type' => 'hit', 'data' => []]);
+ AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => 'control', 'type' => 'success', 'data' => []]);
+ // variant 2: 10 hits, 0 successes = 0% rate
+ for ($i = 0; $i < 10; $i++) {
+ AbTestResult::create(['experiment_id' => $experiment->id(), 'variation' => 'variant_a', 'type' => 'hit', 'data' => []]);
+ }
+
+ $html = makeWidget()->html();
+
+ // control has a higher conversion rate so it should appear as the leader
+ expect($html)->toContain('control');
+ });
+
+ it('links each experiment to its show page', function () {
+ $experiment = tap(Experiment::make('link-test')->title('Link Test')->published(true))->save();
+
+ $html = makeWidget()->html();
+
+ expect($html)->toContain(cp_route('ab.experiments.show', $experiment->id()));
+ });
+
+ it('includes a link to the experiments index', function () {
+ $html = makeWidget()->html();
+
+ expect($html)->toContain(cp_route('ab.experiments.index'));
+ });
+});