Plugin Directory

Changeset 3442225


Ignore:
Timestamp:
01/19/2026 05:48:32 AM (4 weeks ago)
Author:
abtestkit
Message:

Release prep: version 1.0.8

Location:
abtestkit/trunk
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • abtestkit/trunk/abtestkit.php

    r3425058 r3442225  
    44 * Plugin URI:        https://wordpress.org/plugins/abtestkit
    55 * Description:       Split testing for WooCommerce & WordPress, compatible with all page builders, themes & caching plugins.
    6  * Version:           1.0.7
     6 * Version:           1.0.8
    77 * Author:            abtestkit
    88 * License:           GPL-2.0-or-later
     
    9292    return [
    9393        'plugin'   => 'abtestkit',
    94         'version'  => '1.0.7',
     94        'version'  => '1.0.8',
    9595        'site'     => md5( home_url() ), // anonymous hash
    9696        'wp'       => get_bloginfo( 'version' ),
     
    225225        plugins_url( 'assets/js/onboarding.js', __FILE__ ),
    226226        array( 'wp-element', 'wp-components', 'wp-api-fetch' ),
    227         '1.0.7',
     227        '1.0.8',
    228228        true
    229229    );
     
    521521            $split      = max( 0, min( 100, (int) ( $req->get_param( 'split' ) ?? 50 ) ) );
    522522
    523             // Minimum conversions before declaring a winner (wizard dropdown)
    524             $min_conversions         = absint( $req->get_param( 'min_conversions' ) );
    525             $allowed_min_conversions = [ 3, 5, 10 ];
    526             if ( ! in_array( $min_conversions, $allowed_min_conversions, true ) ) {
    527                 $min_conversions = 5;
     523            // Decision rule (wizard dropdown) — controls when a winner may be auto-declared.
     524            $decision_rule = sanitize_key( (string) $req->get_param( 'decision_rule' ) ); // fast|balanced|precise|manual
     525            $decision_mode = sanitize_key( (string) $req->get_param( 'decision_mode' ) ); // auto|manual
     526
     527            $allowed_rules = [ 'fast', 'balanced', 'precise', 'manual' ];
     528            if ( ! in_array( $decision_rule, $allowed_rules, true ) ) {
     529                $decision_rule = 'balanced';
     530            }
     531
     532            // Defaults
     533            $min_impressions = 50;
     534            $min_conversions = 5;
     535            $decision_mode   = ( $decision_mode === 'manual' || $decision_rule === 'manual' ) ? 'manual' : 'auto';
     536
     537            if ( $decision_mode === 'manual' ) {
     538                // Manual mode: never auto-declare a winner.
     539                $min_impressions = 0;
     540                $min_conversions = 0;
     541                $decision_rule   = 'manual';
     542            } else {
     543                // Auto mode: accept explicit thresholds but enforce allowed values.
     544                $mi = absint( $req->get_param( 'min_impressions' ) );
     545                $mc = absint( $req->get_param( 'min_conversions' ) );
     546
     547                $allowed_mi = [ 25, 50, 75 ];
     548                $allowed_mc = [ 3, 5, 10 ];
     549
     550                $min_impressions = in_array( $mi, $allowed_mi, true ) ? $mi : 50;
     551                $min_conversions = in_array( $mc, $allowed_mc, true ) ? $mc : 5;
     552
     553                // Ensure rule presets stay consistent even if JS changes later.
     554                if ( $decision_rule === 'fast' )    { $min_impressions = 25; $min_conversions = 3; }
     555                if ( $decision_rule === 'balanced' ){ $min_impressions = 50; $min_conversions = 5; }
     556                if ( $decision_rule === 'precise' ) { $min_impressions = 75; $min_conversions = 10; }
    528557            }
    529558
     
    684713                'status'          => $start ? 'running' : 'draft',
    685714                'split'           => $split,
    686                 'min_conversions' => $min_conversions,
     715                'decision_rule'   => $decision_rule,
     716                'decision_mode'   => $decision_mode,
     717                'min_impressions' => (int) $min_impressions,
     718                'min_conversions' => (int) $min_conversions,
    687719                'cookie_ttl_days' => 30,
    688720                'started_at'      => $start ? time() : 0,
    689721                'finished_at'     => 0,
     722                'paused_at'       => 0,
     723                'paused_total'    => 0,
    690724                // Mark what kind of test this is
    691725                'kind'            => ( $post_type === 'product' )
     
    693727                    : ( $post_type === 'post' ? 'post' : 'page' ),
    694728            ];
    695 
    696             // Minimum total conversions required before a winner can be declared.
    697             // (Applies to clicks/form submits/add-to-cart, all counted as "clicks" in stats.)
    698             $min_conversions_raw = absint( $req->get_param( 'min_conversions' ) );
    699             $allowed_min_conversions = [ 1, 3, 5, 10, 20, 30 ];
    700             $min_conversions = in_array( $min_conversions_raw, $allowed_min_conversions, true )
    701                 ? $min_conversions_raw
    702                 : 5;
    703             $test['min_conversions'] = (int) $min_conversions;
    704729
    705730            // Attach overrides for product tests so Woo filters can read them.
     
    784809
    785810                foreach ( $tests as $test ) {
     811                    $test_id    = isset( $test['id'] ) ? (string) $test['id'] : '';
    786812                    $control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0;
    787813                    $variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
    788814                    $kind       = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';
     815
     816                    // Auto-lock winners on dashboard load (running tests only).
     817                    // This flips status to "winner" and persists winner + snapshot.
     818                    if ( $test_id !== '' && ( $test['status'] ?? 'paused' ) === 'running' ) {
     819                        abtestkit_pt_maybe_lock_winner( $test_id );
     820
     821                        // Re-load the test so the response reflects new status/winner.
     822                        $fresh = abtestkit_pt_get( $test_id );
     823                        if ( is_array( $fresh ) ) {
     824                            $test = $fresh;
     825                        }
     826                    }
    789827
    790828                    // Base admin previews are different for product tests vs page/post tests.
     
    833871
    834872                    $out[] = [
    835                         'id'          => isset( $test['id'] ) ? (string) $test['id'] : '',
    836                         'name'        => isset( $test['title'] ) ? (string) $test['title'] : '',
    837                         'kind'        => $kind,
    838                         'status'      => isset( $test['status'] ) ? (string) $test['status'] : 'paused',
    839                         'finished_at' => isset( $test['finished_at'] ) ? (int) $test['finished_at'] : 0,
    840                         'winner'      => abtestkit_pt_winner_for_dashboard( $test ),
    841                         'stats'       => abtestkit_pt_stats( $test ),
    842                         'url'         => $url,
     873                        'id'             => isset( $test['id'] ) ? (string) $test['id'] : '',
     874                        'name'           => isset( $test['title'] ) ? (string) $test['title'] : '',
     875                        'kind'           => $kind,
     876                        'status'         => isset( $test['status'] ) ? (string) $test['status'] : 'paused',
     877                        'decision_rule'  => isset( $test['decision_rule'] ) ? (string) $test['decision_rule'] : 'balanced',
     878                        'decision_mode'  => isset( $test['decision_mode'] ) ? (string) $test['decision_mode'] : 'auto',
     879                        'min_impressions'=> isset( $test['min_impressions'] ) ? (int) $test['min_impressions'] : 50,
     880                        'min_conversions'=> isset( $test['min_conversions'] ) ? (int) $test['min_conversions'] : 5,
     881                        'finished_at'    => isset( $test['finished_at'] ) ? (int) $test['finished_at'] : 0,
     882                        'winner'         => abtestkit_pt_winner_for_dashboard( $test ),
     883                        'stats'          => abtestkit_pt_stats( $test ),
     884                        'url'            => $url,
    843885                        // Legacy: keep preview_url pointing at Version A (Version A permalink or forced A for products)
    844                         'preview_url' => $preview_a,
     886                        'preview_url'    => $preview_a,
    845887                        // New: explicit admin preview URLs for A/B
    846                         'preview_a'   => $preview_a,
    847                         'preview_b'   => $preview_b,
     888                        'preview_a'      => $preview_a,
     889                        'preview_b'      => $preview_b,
    848890                    ];
    849891                }
     
    18951937    }
    18961938
    1897     //Parse post content to find if this test has grouped dependencies (conversionFrom)
     1939    // Parse post content to find if this test has grouped dependencies (conversionFrom)
    18981940    $content = get_post_field('post_content', $post_id );
    18991941    $blocks  = parse_blocks( $content );
     
    19151957    $scan( $blocks );
    19161958
    1917     //Get current stats for this test
     1959    // Get current stats for this test
    19181960    $stats_resp = abtestkit_handle_stats( $request );
    19191961    $stats = $stats_resp instanceof WP_REST_Response
    1920            ? $stats_resp->get_data()
    1921            : (array) $stats_resp;
    1922 
    1923     // Defensive fallback: zero data
     1962        ? $stats_resp->get_data()
     1963        : (array) $stats_resp;
     1964
    19241965    $impA = (int) ($stats['A']['impressions'] ?? 0);
    19251966    $clkA = (int) ($stats['A']['clicks'] ?? 0);
     
    19271968    $clkB = (int) ($stats['B']['clicks'] ?? 0);
    19281969
    1929     // Per-test minimum conversions before declaring a winner (defaults to 5).
     1970    // Defaults (legacy)
     1971    $decisionMode   = 'auto';
     1972    $minImpressions = 50;
    19301973    $minConversions = 5;
    1931     foreach ( (array) abtestkit_pt_all() as $t ) {
    1932         if ( isset( $t['id'] ) && (string) $t['id'] === (string) $abTestId ) {
    1933             $mc = isset( $t['min_conversions'] ) ? absint( $t['min_conversions'] ) : 0;
    1934             if ( $mc > 0 ) {
    1935                 $minConversions = $mc;
    1936             }
    1937             break;
    1938         }
    1939     }
    1940 
    1941     // ── Cache evaluation for this exact stats state (60s) ───────────────────────
     1974
     1975    // Page Test Wizard (pt-*) thresholds + manual mode
     1976    if ( strpos( (string) $abTestId, 'pt-' ) === 0 ) {
     1977        $pt_test = abtestkit_pt_get( (string) $abTestId );
     1978        if ( is_array( $pt_test ) ) {
     1979            $decisionMode = sanitize_key( (string) ( $pt_test['decision_mode'] ?? 'auto' ) );
     1980            $decisionRule = sanitize_key( (string) ( $pt_test['decision_rule'] ?? 'balanced' ) );
     1981
     1982            if ( $decisionMode === 'manual' || $decisionRule === 'manual' ) {
     1983                $decisionMode   = 'manual';
     1984                $minImpressions = 0;
     1985                $minConversions = 0;
     1986            } else {
     1987                $mi = isset( $pt_test['min_impressions'] ) ? absint( $pt_test['min_impressions'] ) : 0;
     1988                $mc = isset( $pt_test['min_conversions'] ) ? absint( $pt_test['min_conversions'] ) : 0;
     1989
     1990                $minImpressions = in_array( $mi, [ 25, 50, 75 ], true ) ? $mi : 50;
     1991                $minConversions = in_array( $mc, [ 3, 5, 10 ], true ) ? $mc : 5;
     1992            }
     1993        }
     1994    }
     1995
     1996    // Cache evaluation for this exact stats state (60s)
    19421997    $eval_cache_key = sprintf(
    1943         'abtestkit_eval:%d:%s:%d:%d:%d:%d:%d',
    1944         (int) $post_id, (string) $abTestId, $impA, $clkA, $impB, $clkB, (int) $minConversions
     1998        'abtestkit_eval:%d:%s:%d:%d:%d:%d:%s:%d:%d',
     1999        (int) $post_id, (string) $abTestId, $impA, $clkA, $impB, $clkB,
     2000        (string) $decisionMode, (int) $minImpressions, (int) $minConversions
    19452001    );
    19462002    $cached_eval = wp_cache_get( $eval_cache_key, 'abtestkit_eval' );
     
    19492005    }
    19502006
    1951 
    1952     // ── Early exits + global minimum impression threshold ────────────────────────
    1953     if ($impA === 0 && $impB === 0) {
    1954         return rest_ensure_response([
    1955             'probA' => 0.5, 'probB' => 0.5, 'winner' => '',
    1956             'message' => 'No impressions recorded yet.',
    1957         ]);
    1958     }
    1959     if ($impA === 0 || $impB === 0) {
    1960         return rest_ensure_response([
    1961             'probA' => 0.5, 'probB' => 0.5, 'winner' => '',
    1962             'message' => 'Only one variant has impressions — test needs more data.',
    1963         ]);
    1964     }
    1965 
    1966     // Minimum total impressions (A + B) required before Bayesian evaluation
    1967     $minImpressions = 50; // <- your requested threshold
     2007    // Early exits
     2008    if ( $impA === 0 && $impB === 0 ) {
     2009        $out = [ 'probA' => 0.5, 'probB' => 0.5, 'winner' => '', 'message' => 'No impressions recorded yet.' ];
     2010        wp_cache_set( $eval_cache_key, $out, 'abtestkit_eval', 60 );
     2011        return rest_ensure_response( $out );
     2012    }
     2013    if ( $impA === 0 || $impB === 0 ) {
     2014        $out = [ 'probA' => 0.5, 'probB' => 0.5, 'winner' => '', 'message' => 'Only one variant has impressions — test needs more data.' ];
     2015        wp_cache_set( $eval_cache_key, $out, 'abtestkit_eval', 60 );
     2016        return rest_ensure_response( $out );
     2017    }
     2018
    19682019    $totalImpressions = $impA + $impB;
    1969     if ($totalImpressions < $minImpressions) {
    1970         return rest_ensure_response([
    1971             'probA' => 0.5,
    1972             'probB' => 0.5,
    1973             'winner' => '',
    1974             'message' => sprintf(
    1975                 'Not enough data yet — %d/%d total impressions.',
    1976                 $totalImpressions,
    1977                 $minImpressions
    1978             ),
    1979         ]);
    1980     }
    1981 
    1982     // Minimum total conversions (A + B) required before Bayesian evaluation
    1983     // can declare a winner.
    19842020    $totalConversions = $clkA + $clkB;
    1985     if ( $totalConversions < (int) $minConversions ) {
    1986         return rest_ensure_response([
    1987             'probA' => 0.5,
    1988             'probB' => 0.5,
    1989             'winner' => '',
    1990             'message' => sprintf(
    1991                 'Not enough conversions yet — %d/%d total conversions.',
    1992                 $totalConversions,
    1993                 (int) $minConversions
    1994             ),
    1995         ]);
    1996     }
    1997 
    1998     //Apply Bayesian prior and sample distributions
    1999     $priorN = 10;
     2021
     2022    // Auto mode gates
     2023    if ( $decisionMode !== 'manual' ) {
     2024        if ( $totalImpressions < (int) $minImpressions ) {
     2025            $out = [
     2026                'probA' => 0.5, 'probB' => 0.5, 'winner' => '',
     2027                'message' => sprintf( 'Not enough data yet — %d/%d total impressions.', $totalImpressions, (int) $minImpressions ),
     2028            ];
     2029            wp_cache_set( $eval_cache_key, $out, 'abtestkit_eval', 60 );
     2030            return rest_ensure_response( $out );
     2031        }
     2032
     2033        if ( $totalConversions < (int) $minConversions ) {
     2034            $out = [
     2035                'probA' => 0.5, 'probB' => 0.5, 'winner' => '',
     2036                'message' => sprintf( 'Not enough conversions yet — %d/%d total conversions.', $totalConversions, (int) $minConversions ),
     2037            ];
     2038            wp_cache_set( $eval_cache_key, $out, 'abtestkit_eval', 60 );
     2039            return rest_ensure_response( $out );
     2040        }
     2041    }
     2042
     2043    // Bayesian sampling
     2044    $priorN = 2;
    20002045    $alphaA = $priorN/2 + $clkA;
    20012046    $betaA  = $priorN/2 + max(0, $impA - $clkA);
     
    20182063    $probA = $countA / $numSamples;
    20192064    $probB = 1 - $probA;
     2065
     2066    // Confidence level by rule:
     2067    // - Fast (25 impressions / 3 conversions): 90%
     2068    // - Balanced + Precise: 95%
     2069    $confidence = 0.95;
     2070    if ( $decisionMode !== 'manual' ) {
     2071        // Prefer the saved rule label if present, otherwise infer from thresholds.
     2072        if (
     2073            ( isset( $decisionRule ) && $decisionRule === 'fast' )
     2074            || ( (int) $minImpressions === 25 && (int) $minConversions === 3 )
     2075        ) {
     2076            $confidence = 0.90;
     2077        }
     2078    }
     2079
    20202080    sort( $diffs );
    2021     $ciLower = $diffs[ (int) (0.025 * $numSamples) ];
    2022     $ciUpper = $diffs[ (int) (0.975 * $numSamples) ];
    2023 
    2024     // Page Test Wizard: minimum conversions gate (stored on pt-* tests)
    2025     $minConversions = 5;
    2026     if ( strpos( (string) $abTestId, 'pt-' ) === 0 ) {
    2027         $pt_test = abtestkit_pt_get( (string) $abTestId );
    2028         if ( is_array( $pt_test ) && isset( $pt_test['min_conversions'] ) ) {
    2029             $minConversions = (int) $pt_test['min_conversions'];
    2030         }
    2031     }
    2032     $allowedMinConversions = [ 3, 5, 10 ];
    2033     if ( ! in_array( $minConversions, $allowedMinConversions, true ) ) {
    2034         $minConversions = 5;
    2035     }
    2036 
    2037     $totalConversions = $clkA + $clkB;
    2038 
     2081    $tail   = ( 1 - $confidence ) / 2; // e.g. 0.05 for 90%, 0.025 for 95%
     2082    $ciLower = $diffs[ (int) ( $tail * $numSamples ) ];
     2083    $ciUpper = $diffs[ (int) ( ( 1 - $tail ) * $numSamples ) ];
     2084
     2085    // Winner decision (never in manual mode)
    20392086    $winner = '';
    2040     if ( $totalConversions >= $minConversions ) {
    2041         if ( $probA > 0.95 && $ciLower > 0 ) {
     2087    if ( $decisionMode !== 'manual' ) {
     2088        if ( $probA > $confidence && $ciLower > 0 ) {
    20422089            $winner = 'A';
    2043         } elseif ( $probB > 0.95 && $ciUpper < 0 ) {
     2090        } elseif ( $probB > $confidence && $ciUpper < 0 ) {
    20442091            $winner = 'B';
    20452092        }
     
    20532100        'winner'  => $winner
    20542101    ];
    2055     // cache for a short time; cache key includes the exact counts so it auto-invalidates
     2102
    20562103    wp_cache_set( $eval_cache_key, $result, 'abtestkit_eval', 60 );
    20572104    return rest_ensure_response( $result );
     
    24822529        }
    24832530
     2531        // Normalise pause bookkeeping fields so older installs don't break.
     2532        $paused_at = isset( $t['paused_at'] ) ? (int) $t['paused_at'] : 0;
     2533        if ( $paused_at < 0 ) { $paused_at = 0; }
     2534        if ( ! isset( $t['paused_at'] ) || (int) $t['paused_at'] !== $paused_at ) {
     2535            $t['paused_at'] = $paused_at;
     2536            $changed        = true;
     2537        }
     2538
     2539        $paused_total = isset( $t['paused_total'] ) ? (int) $t['paused_total'] : 0;
     2540        if ( $paused_total < 0 ) { $paused_total = 0; }
     2541        if ( ! isset( $t['paused_total'] ) || (int) $t['paused_total'] !== $paused_total ) {
     2542            $t['paused_total'] = $paused_total;
     2543            $changed           = true;
     2544        }
     2545
     2546        $started_at = isset( $t['started_at'] ) ? (int) $t['started_at'] : 0;
     2547        if ( $started_at < 0 ) { $started_at = 0; }
     2548        if ( ! isset( $t['started_at'] ) || (int) $t['started_at'] !== $started_at ) {
     2549            $t['started_at'] = $started_at;
     2550            $changed         = true;
     2551        }
     2552
    24842553        // Safety: if a "running" test has lost both page references, pause it.
    24852554        if ( $t['status'] === 'running' ) {
     
    24932562        }
    24942563
    2495         // Normalise min_conversions – default to 5 and only allow 3, 5, 10.
    2496         $allowed_min_conversions = [ 3, 5, 10 ];
    2497         $mc = isset( $t['min_conversions'] ) ? (int) $t['min_conversions'] : 0;
    2498         if ( ! in_array( $mc, $allowed_min_conversions, true ) ) {
    2499             $t['min_conversions'] = 5;
    2500             $changed              = true;
     2564        // Decision settings (new) — defaults for older installs/tests.
     2565        if ( empty( $t['decision_rule'] ) ) {
     2566            $t['decision_rule'] = 'balanced';
     2567            $changed            = true;
     2568        }
     2569        if ( empty( $t['decision_mode'] ) ) {
     2570            // If rule is manual, mode must be manual.
     2571            $t['decision_mode'] = ( (string) $t['decision_rule'] === 'manual' ) ? 'manual' : 'auto';
     2572            $changed            = true;
     2573        }
     2574
     2575        // Normalise thresholds.
     2576        $rule = (string) $t['decision_rule'];
     2577        $mode = (string) $t['decision_mode'];
     2578
     2579        // Manual mode: never auto-declare winner => allow 0/0 thresholds.
     2580        if ( $mode === 'manual' || $rule === 'manual' ) {
     2581            if ( (int) ( $t['min_impressions'] ?? -1 ) !== 0 ) {
     2582                $t['min_impressions'] = 0;
     2583                $changed              = true;
     2584            }
     2585            if ( (int) ( $t['min_conversions'] ?? -1 ) !== 0 ) {
     2586                $t['min_conversions'] = 0;
     2587                $changed              = true;
     2588            }
     2589        } else {
     2590            // Auto mode: only allow preset thresholds.
     2591            $allowed_mi = [ 25, 50, 75 ];
     2592            $allowed_mc = [ 3, 5, 10 ];
     2593
     2594            $mi = isset( $t['min_impressions'] ) ? (int) $t['min_impressions'] : 0;
     2595            $mc = isset( $t['min_conversions'] ) ? (int) $t['min_conversions'] : 0;
     2596
     2597            if ( ! in_array( $mi, $allowed_mi, true ) ) {
     2598                $t['min_impressions'] = 50;
     2599                $changed              = true;
     2600            }
     2601            if ( ! in_array( $mc, $allowed_mc, true ) ) {
     2602                $t['min_conversions'] = 5;
     2603                $changed              = true;
     2604            }
     2605
     2606            // Keep rule presets consistent even if data got edited.
     2607            if ( $rule === 'fast' )    { $t['min_impressions'] = 25; $t['min_conversions'] = 3; $changed = true; }
     2608            if ( $rule === 'balanced' ){ $t['min_impressions'] = 50; $t['min_conversions'] = 5; $changed = true; }
     2609            if ( $rule === 'precise' ) { $t['min_impressions'] = 75; $t['min_conversions'] = 10; $changed = true; }
    25012610        }
    25022611    }
     
    30633172                    'started_at'      => 0,
    30643173                    'finished_at'     => 0,
     3174                    'paused_at'       => 0,
     3175                    'paused_total'    => 0,
    30653176                    'kind'            => ( $post_type === 'product' ) ? 'product' : 'page',
    30663177                ];
     
    31013212                exit;
    31023213            }
    3103             $test['status']    = 'running';
    3104             $test['started_at'] = time();
     3214            // Preserve started_at across pause/resume, and exclude pause time from runtime.
     3215            $prev_status = isset( $test['status'] ) ? (string) $test['status'] : 'paused';
     3216
     3217            // If resuming from paused, shift started_at forward by the paused duration
     3218            // so that pause time does not count towards the test length.
     3219            if ( $prev_status === 'paused' ) {
     3220                $paused_at = isset( $test['paused_at'] ) ? (int) $test['paused_at'] : 0;
     3221                if ( $paused_at > 0 ) {
     3222                    $delta = max( 0, time() - $paused_at );
     3223                    $test['paused_total'] = (int) ( $test['paused_total'] ?? 0 ) + $delta;
     3224                    $test['started_at']   = (int) ( $test['started_at'] ?? 0 ) + $delta;
     3225                    $test['paused_at']    = 0;
     3226                }
     3227            }
     3228
     3229            // Only set started_at on the first start (draft/new tests).
     3230            if ( (int) ( $test['started_at'] ?? 0 ) <= 0 || $prev_status === 'draft' ) {
     3231                $test['started_at'] = time();
     3232            }
     3233
     3234            $test['status'] = 'running';
    31053235            abtestkit_pt_put( $test );
    31063236            break;
    31073237        case 'pause':
     3238            // Record when the pause started so we can exclude it from runtime on resume.
     3239            if ( ( $test['status'] ?? 'paused' ) === 'running' ) {
     3240                $test['paused_at'] = time();
     3241                if ( ! isset( $test['paused_total'] ) ) {
     3242                    $test['paused_total'] = 0;
     3243                }
     3244            }
     3245
    31083246            $test['status'] = 'paused';
    31093247            abtestkit_pt_put( $test );
     
    31343272            $kind = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';
    31353273
     3274            // Snapshot now (before we overwrite A / commit overrides / trash B).
     3275            abtestkit_pt_snapshot_completed_test( $test );
     3276
    31363277            if ( $kind === 'product' ) {
    31373278                $product_id = (int) $test['control_id'];
     
    31873328                // End the test: mark as complete so it disappears from "running" actions
    31883329                // and is no longer considered in variant assignment.
    3189                 $test['status']      = 'complete';
    3190                 $test['finished_at'] = time();
    3191                 abtestkit_pt_put( $test );
     3330                    $test['status']      = 'complete';
     3331                    $test['finished_at'] = time();
     3332                    abtestkit_pt_snapshot_completed_test( $test );
     3333                    abtestkit_pt_put( $test );
    31923334                break;
    31933335            }
     
    32693411
    32703412/** Stats helper (reads abtestkit_events) */
     3413/**
     3414 * Compute stats for snapshotting (by ab_test_id only).
     3415 * This avoids "0 stats" issues when events don't have a reliable post_id (e.g. Woo add_to_cart / AJAX).
     3416 */
     3417function abtestkit_pt_stats_for_snapshot( string $test_id ) : array {
     3418    global $wpdb;
     3419
     3420    $out = [
     3421        'A' => [ 'impressions' => 0, 'clicks' => 0 ],
     3422        'B' => [ 'impressions' => 0, 'clicks' => 0 ],
     3423    ];
     3424
     3425    if ( $test_id === '' ) {
     3426        return $out;
     3427    }
     3428
     3429    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
     3430    $rows = $wpdb->get_results(
     3431        $wpdb->prepare(
     3432            'SELECT variant, event_type, COUNT(*) AS c ' .
     3433            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
     3434            'FROM `' . ABTESTKIT_EVENTS_TABLE . '` ' .
     3435            'WHERE ab_test_id = %s ' .
     3436            "AND event_type IN ('impression','click') " .
     3437            'GROUP BY variant, event_type',
     3438            $test_id
     3439        ),
     3440        ARRAY_A
     3441    );
     3442
     3443    foreach ( (array) $rows as $r ) {
     3444        $v = isset( $r['variant'] ) ? $r['variant'] : '';
     3445        $t = isset( $r['event_type'] ) ? $r['event_type'] : '';
     3446        $c = isset( $r['c'] ) ? (int) $r['c'] : 0;
     3447
     3448        if ( isset( $out[ $v ][ $t . 's' ] ) ) {
     3449            $out[ $v ][ $t . 's' ] += $c;
     3450        }
     3451    }
     3452
     3453    return $out;
     3454}
     3455
     3456function abtestkit_pt_capture_post_snapshot( int $post_id ) : array {
     3457    $p = $post_id > 0 ? get_post( $post_id ) : null;
     3458    if ( ! ( $p instanceof WP_Post ) ) {
     3459        return [];
     3460    }
     3461
     3462    return [
     3463        'id'            => (int) $p->ID,
     3464        'post_type'     => (string) $p->post_type,
     3465        'post_status'   => (string) $p->post_status,
     3466        'post_title'    => (string) $p->post_title,
     3467        'post_name'     => (string) $p->post_name,
     3468        'post_date_gmt' => (string) $p->post_date_gmt,
     3469        'modified_gmt'  => (string) $p->post_modified_gmt,
     3470        'post_excerpt'  => (string) $p->post_excerpt,
     3471        'post_content'  => (string) $p->post_content,
     3472    ];
     3473}
     3474
     3475function abtestkit_pt_capture_product_snapshot( int $product_id ) : array {
     3476    $p = $product_id > 0 ? get_post( $product_id ) : null;
     3477    if ( ! ( $p instanceof WP_Post ) ) {
     3478        return [];
     3479    }
     3480
     3481    $regular = get_post_meta( $product_id, '_regular_price', true );
     3482    $sale    = get_post_meta( $product_id, '_sale_price', true );
     3483    $price   = get_post_meta( $product_id, '_price', true );
     3484
     3485    if ( function_exists( 'wc_get_product' ) ) {
     3486        $prod = wc_get_product( $product_id );
     3487        if ( $prod ) {
     3488            $regular = $prod->get_regular_price();
     3489            $sale    = $prod->get_sale_price();
     3490            $price   = $prod->get_price();
     3491        }
     3492    }
     3493
     3494    $image_id    = (int) get_post_thumbnail_id( $product_id );
     3495    $gallery_csv = (string) get_post_meta( $product_id, '_product_image_gallery', true );
     3496    $gallery_ids = [];
     3497    if ( $gallery_csv !== '' ) {
     3498        $gallery_ids = array_values(
     3499            array_filter(
     3500                array_map(
     3501                    'intval',
     3502                    array_map( 'trim', explode( ',', $gallery_csv ) )
     3503                )
     3504            )
     3505        );
     3506    }
     3507
     3508    return [
     3509        'id'                => (int) $p->ID,
     3510        'post_title'        => (string) $p->post_title,
     3511        'short_description' => (string) $p->post_excerpt,
     3512        'description'       => (string) $p->post_content,
     3513        'regular_price'     => (string) $regular,
     3514        'sale_price'        => (string) $sale,
     3515        'price'             => (string) $price,
     3516        'image_id'          => (int) $image_id,
     3517        'gallery_ids'       => $gallery_ids,
     3518    ];
     3519}
     3520
     3521/**
     3522 * Snapshot the "final" state of a test so Completed/Winner tabs remain stable forever.
     3523 * - final_stats: A/B impressions+clicks computed by ab_test_id
     3524 * - snapshot_versions: the exact A and B versions at snapshot time
     3525 */
     3526function abtestkit_pt_snapshot_completed_test( array &$test ) : void {
     3527    $test_id = isset( $test['id'] ) ? (string) $test['id'] : '';
     3528    if ( $test_id === '' ) {
     3529        return;
     3530    }
     3531
     3532    if ( empty( $test['final_stats'] ) || ! is_array( $test['final_stats'] ) ) {
     3533        $test['final_stats'] = abtestkit_pt_stats_for_snapshot( $test_id );
     3534    }
     3535
     3536    if ( empty( $test['snapshot_at'] ) ) {
     3537        $test['snapshot_at'] = time();
     3538    }
     3539
     3540    if ( empty( $test['snapshot_versions'] ) || ! is_array( $test['snapshot_versions'] ) ) {
     3541        $kind = isset( $test['kind'] ) ? (string) $test['kind'] : 'page';
     3542
     3543        if ( $kind === 'product' ) {
     3544            $a = abtestkit_pt_capture_product_snapshot( (int) ( $test['control_id'] ?? 0 ) );
     3545
     3546            // For product tests, B might be "virtual" via overrides (variant_id can be 0).
     3547            $b = [];
     3548            $variant_id = (int) ( $test['variant_id'] ?? 0 );
     3549            if ( $variant_id > 0 ) {
     3550                $b = abtestkit_pt_capture_product_snapshot( $variant_id );
     3551            } else {
     3552                $b = $a;
     3553                $overrides = ( isset( $test['overrides'] ) && is_array( $test['overrides'] ) ) ? $test['overrides'] : [];
     3554                foreach ( $overrides as $k => $v ) {
     3555                    $b[ $k ] = $v;
     3556                }
     3557                $b['virtual'] = true;
     3558            }
     3559
     3560            $test['snapshot_versions'] = [
     3561                'A' => $a,
     3562                'B' => $b,
     3563            ];
     3564        } else {
     3565            $test['snapshot_versions'] = [
     3566                'A' => abtestkit_pt_capture_post_snapshot( (int) ( $test['control_id'] ?? 0 ) ),
     3567                'B' => abtestkit_pt_capture_post_snapshot( (int) ( $test['variant_id'] ?? 0 ) ),
     3568            ];
     3569        }
     3570    }
     3571}
     3572
    32713573function abtestkit_pt_stats( array $test ) : array {
     3574    // If Winner/Complete and we have a snapshot, ALWAYS use it.
     3575    $status = isset( $test['status'] ) ? (string) $test['status'] : 'paused';
     3576    if ( in_array( $status, [ 'winner', 'complete' ], true )
     3577        && isset( $test['final_stats'] )
     3578        && is_array( $test['final_stats'] )
     3579        && isset( $test['final_stats']['A'], $test['final_stats']['B'] )
     3580    ) {
     3581        return $test['final_stats'];
     3582    }
     3583
     3584    // Fallback: compute live (existing behaviour).
    32723585    global $wpdb;
    32733586
     
    32753588    $variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0;
    32763589
    3277     // Safety: if somehow no control page, just return zeros.
    32783590    if ( $control_id <= 0 ) {
    32793591        return [
     
    32833595    }
    32843596
    3285     // Direct read from a custom log table (no core abstraction available).
    32863597    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    32873598    $rows = $wpdb->get_results(
    32883599        $wpdb->prepare(
    32893600            'SELECT variant, event_type, COUNT(*) AS c ' .
    3290             // Custom table name is fixed (prefix + constant suffix). Safe to concatenate.
    32913601            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
    32923602            'FROM `' . ABTESTKIT_EVENTS_TABLE . '` ' .
     
    33023612
    33033613    $out = [
    3304         'A' => [
    3305             'impressions' => 0,
    3306             'clicks'      => 0,
    3307         ],
    3308         'B' => [
    3309             'impressions' => 0,
    3310             'clicks'      => 0,
    3311         ],
     3614        'A' => [ 'impressions' => 0, 'clicks' => 0 ],
     3615        'B' => [ 'impressions' => 0, 'clicks' => 0 ],
    33123616    ];
    33133617
     
    33243628    return $out;
    33253629}
     3630
    33263631
    33273632/**
     
    33343639 */
    33353640function abtestkit_pt_winner_for_dashboard( array $test ) : string {
     3641    // If we already locked a winner, always show it.
    33363642    $locked = isset( $test['winner'] ) ? sanitize_text_field( (string) $test['winner'] ) : '';
    33373643    if ( in_array( $locked, [ 'A', 'B' ], true ) ) {
    33383644        return $locked;
     3645    }
     3646
     3647    // IMPORTANT: do not evaluate paused/draft/complete tests.
     3648    // This prevents paused/draft tests from appearing as "Winner" in the dashboard UI.
     3649    $status = isset( $test['status'] ) ? (string) $test['status'] : 'paused';
     3650    if ( $status !== 'running' ) {
     3651        return '';
    33393652    }
    33403653
     
    33683681        return;
    33693682    }
     3683
     3684    // Manual mode: never auto-lock a winner.
     3685    // Guard both decision_mode and decision_rule (older/newer tests may set either).
     3686    if ( ( $test['decision_mode'] ?? 'auto' ) === 'manual' || ( $test['decision_rule'] ?? '' ) === 'manual' ) {
     3687        return;
     3688    }
     3689
    33703690    $already = isset( $test['winner'] ) ? sanitize_text_field( (string) $test['winner'] ) : '';
    33713691    if ( in_array( $already, [ 'A', 'B' ], true ) ) {
     
    33943714    $test['status']    = 'winner'; // Paused automatically, but with an explicit Winner state.
    33953715
     3716    abtestkit_pt_snapshot_completed_test( $test );
    33963717    abtestkit_pt_put( $test );
    33973718}
    3398 
    33993719
    34003720// Dashboard Render //
     
    34483768        plugins_url( 'assets/js/pt-wizard.js', __FILE__ ),
    34493769        [ 'wp-element', 'wp-components', 'wp-api-fetch', 'wp-editor' ],
    3450         '1.0.7',
     3770        '1.0.8',
    34513771        true
    34523772    );
     
    34713791        plugins_url( 'assets/js/admin-list-guard.js', __FILE__ ),
    34723792        [ 'jquery' ],
    3473         ( defined( 'ABTESTKIT_VERSION' ) ? ABTESTKIT_VERSION : '1.0.7' ),
     3793        ( defined( 'ABTESTKIT_VERSION' ) ? ABTESTKIT_VERSION : '1.0.8' ),
    34743794        true
    34753795    );
  • abtestkit/trunk/assets/js/dashboard.js

    r3425058 r3442225  
    251251                  'Impressions are the number of times each version was shown to visitors.',
    252252                  'Traffic is split randomly across versions.',
    253                   'A minimum of 50 impressions is needed before a winner can be decided.',
     253                  'Minimum impressions are set per test (Fast / Balanced / Precise).',
     254                  'Manual mode never auto-declares a winner.',
    254255                ])
    255256              ]
     
    423424                };
    424425
     426                const isManual = (t.decision_mode || '') === 'manual' || (t.decision_rule || '') === 'manual';
     427
     428                const applyCurrentLeader = () => {
     429                  const stats = t.stats || { A: {}, B: {} };
     430                  const impA = (stats.A && stats.A.impressions) || 0;
     431                  const impB = (stats.B && stats.B.impressions) || 0;
     432                  const clkA = (stats.A && stats.A.clicks) || 0;
     433                  const clkB = (stats.B && stats.B.clicks) || 0;
     434
     435                  const rateA = impA > 0 ? clkA / impA : 0;
     436                  const rateB = impB > 0 ? clkB / impB : 0;
     437
     438                  if ((impA + impB) === 0) {
     439                    alert('No impressions yet — wait for some traffic before applying a leader.');
     440                    return;
     441                  }
     442
     443                  const leader = rateB > rateA ? 'B' : 'A';
     444
     445                  if (leader === 'A') {
     446                    const alsoDeleteVariant = confirm(
     447                      'Current leader is Version A. End the test and keep A as-is?\n\nClick OK to also delete Version B, or Cancel to keep Version B.'
     448                    );
     449                    submitPtAction('keep_a_winner', { trash_b: alsoDeleteVariant ? '1' : '0' });
     450                    return;
     451                  }
     452
     453                  submitPtAction('apply_b_winner');
     454                };
     455
    425456                if (isRunning) {
    426                   actionButtons.push(makeButton('pause', 'Pause Test', pauseTest));
     457                  if (isManual) {
     458                    actionButtons.push(
     459                      makeButton('apply-current', 'Apply Current Leader', applyCurrentLeader),
     460                      makeButton('pause', 'Pause Test', pauseTest)
     461                    );
     462                  } else {
     463                    actionButtons.push(makeButton('pause', 'Pause Test', pauseTest));
     464                  }
    427465                } else if (isWinner) {
    428466                  actionButtons.push(
     
    673711                      },
    674712                    },
    675                     isWinner ? 'Winner' : (t.status || 'paused')
     713                    isWinner
     714                    ? 'Winner'
     715                    : (t.status === 'running' && ((t.decision_mode || '') === 'manual' || (t.decision_rule || '') === 'manual'))
     716                    ? 'Running (Manual)'
     717                    : (t.status || 'paused')
    676718                  ),
    677719                  h(
     
    775817              })
    776818        )
     819      ),
     820
     821      // ── Dashboard footer links ─────────────────────────────
     822      h(
     823        'div',
     824        {
     825          style: {
     826            marginTop: '24px',
     827            paddingTop: '12px',
     828            borderTop: '1px solid #e5e7eb',
     829            display: 'flex',
     830            justifyContent: 'center',
     831            gap: '16px',
     832            fontSize: '13px',
     833          },
     834        },
     835        [
     836          h(
     837            'a',
     838            {
     839              href:
     840                'mailto:[email protected]' +
     841                '?subject=' +
     842                encodeURIComponent('Feature request') +
     843                '&body=' +
     844                encodeURIComponent(
     845                  'Hi abtestkit team,\n\nI have a feature request:\n\n'
     846                ),
     847              style: {
     848                textDecoration: 'none',
     849                color: '#2271b1',
     850                cursor: 'pointer',
     851              },
     852            },
     853            'Request a feature'
     854          ),
     855          h(
     856            'a',
     857            {
     858              href:
     859                'mailto:[email protected]' +
     860                '?subject=' +
     861                encodeURIComponent('Support request') +
     862                '&body=' +
     863                encodeURIComponent(
     864                  'Hi abtestkit team,\n\nI need help with\n\n'
     865                ),
     866              style: {
     867                textDecoration: 'none',
     868                color: '#2271b1',
     869                cursor: 'pointer',
     870              },
     871            },
     872            'Support'
     873          ),
     874        ]
    777875      )
    778876    );
  • abtestkit/trunk/assets/js/pt-wizard.js

    r3425058 r3442225  
    512512
    513513
    514   /* Key-value row used in the summary step */
    515   const ListItem = ({ label, value }) =>
     514  /* Key-value row used in the summary step (readable + dividers) */
     515  const ListItem = ({ label, value, noBorder = false }) =>
    516516    h(
    517517      "div",
    518       { style: { display: "flex", justifyContent: "space-between", padding: "6px 0" } },
    519       [h("div", { style: { color: "#6c7781" } }, label), h("div", null, value)]
     518      {
     519        style: {
     520          display: "grid",
     521          gridTemplateColumns: "190px 1fr", // keeps values close to labels on wide screens
     522          gap: 12,
     523          alignItems: "start",
     524          padding: "10px 0",
     525          borderBottom: noBorder ? "none" : "1px solid #e5e7eb",
     526        },
     527      },
     528      [
     529        h(
     530          "div",
     531          {
     532            style: {
     533              color: "#6c7781",
     534              fontSize: 13,
     535              lineHeight: "1.35",
     536              paddingTop: 2,
     537            },
     538          },
     539          label
     540        ),
     541        h(
     542          "div",
     543          {
     544            style: {
     545              minWidth: 0,
     546              fontSize: 13,
     547              lineHeight: "1.45",
     548              wordBreak: "break-word",
     549            },
     550          },
     551          value
     552        ),
     553      ]
    520554    );
    521555
     
    751785
    752786    const [goal, setGoal] = useState("");
    753     const [minConversions, setMinConversions] = useState("5"); // minimum total conversions before declaring a winner
     787
     788    // Decision rule (min impressions + min conversions, or manual)
     789    // fast     = 25 impressions + 3 conversions
     790    // balanced = 50 impressions + 5 conversions
     791    // precise  = 75 impressions + 10 conversions
     792    // manual   = never auto-declares a winner
     793    const [decisionRule, setDecisionRule] = useState("balanced");
     794
    754795    const [productPreviewToken, setProductPreviewToken] = useState("");
    755796    const [links, setLinks] = useState(""); // comma-separated
     
    10371078
    10381079      // Base payload used for normal page tests
     1080      // Map decision rules -> thresholds
     1081      const decisionMap = {
     1082        fast:     { min_impressions: 25, min_conversions: 3,  decision_mode: "auto" },
     1083        balanced: { min_impressions: 50, min_conversions: 5,  decision_mode: "auto" },
     1084        precise:  { min_impressions: 75, min_conversions: 10, decision_mode: "auto" },
     1085        manual:   { min_impressions: 0,  min_conversions: 0,  decision_mode: "manual" },
     1086      };
     1087
     1088      const chosen = decisionMap[String(decisionRule || "balanced")] || decisionMap.balanced;
     1089
    10391090      const payload = {
    10401091        control_id: pageA.id,
     
    10441095        split: 50,
    10451096        goal,
    1046         min_conversions: parseInt(minConversions, 10) || 5,
     1097
     1098        // NEW: decision rule + thresholds
     1099        decision_rule: String(decisionRule || "balanced"),
     1100        decision_mode: String(chosen.decision_mode || "auto"),
     1101        min_impressions: Number.isFinite(chosen.min_impressions) ? chosen.min_impressions : 50,
     1102        min_conversions: Number.isFinite(chosen.min_conversions) ? chosen.min_conversions : 5,
     1103
    10471104        links: links.split(",").map((s) => s.trim()).filter(Boolean),
    10481105      };
     
    25662623                    display: "flex",
    25672624                    flexDirection: "column",
    2568                     alignItems: "flex-end",
     2625                    alignItems: "flex-start", // left-align Version B details
    25692626                    lineHeight: "1.4",
    25702627                    gap: "2px",
     
    26592716        { style: { marginTop: 12 } },
    26602717        h(SelectControl, {
    2661           label: "Minimum conversions needed",
    2662           value: String(minConversions),
     2718          label: "AUTO TEST RULE",
     2719          value: String(decisionRule),
    26632720          options: [
    2664             { label: "3 (Fast, less precise)", value: "3" },
    2665             { label: "5 (Balanced)", value: "5" },
    2666             { label: "10 (Precise)", value: "10" },
     2721            { label: "25 impressions + 3 conversions (Fast, less precise)", value: "fast" },
     2722            { label: "50 impressions + 5 conversions (Balanced)", value: "balanced" },
     2723            { label: "75 impressions + 10 conversions (Precise)", value: "precise" },
     2724            { label: "Manual (never auto-confirm a winner)", value: "manual" },
    26672725          ],
    2668           onChange: (v) => setMinConversions(v),
     2726          onChange: (v) => setDecisionRule(v),
    26692727          help:
    2670             "Recommended: 5 for smaller sites, 10 for higher-traffic sites.",
     2728            String(decisionRule) === "manual"
     2729              ? "Manual mode never auto-confirms a winner. You can apply the current leader from the dashboard."
     2730              : "A winner will not be declared until both the impressions and conversions minimums are met.",
    26712731        })
    26722732      ),
  • abtestkit/trunk/readme.txt

    r3425058 r3442225  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 1.0.7
     7Stable tag: 1.0.8
    88License: GPL-2.0-or-later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    1515= The simplest way to A/B test in WordPress =
    1616
    17 **abtestkit** lets you run clean, fast, privacy-friendly AB tests without coding or complicated interfaces. 
     17**abtestkit** lets you run clean, fast, privacy-friendly AB tests without ANY coding or complicated interfaces. 
    1818Create full-page split tests in seconds, track performance automatically, and apply the winner with one click.
    1919
     
    2828* **No analysis needed** - abtestkit tracks impressions & clicks, then automatically declares the winning variant with 95% confidence. 
    2929
    30 abtestkit isn't just another plugin. It's a growth tool that helps you experiment, learn, and keep moving forward.
     30abtestkit is a growth tool that helps you experiment, learn, and keep moving forward.
    3131
    3232### Use cases
     
    7777== Changelog ==
    7878
     79= 1.0.8 =
     80* Manual tests added
     81* Fast testing added
     82* UI on Dashboard and Test Creation Wizard improved
     83
    7984= 1.0.7 =
    8085* Previewing version improvements
     
    119124== Upgrade Notice ==
    120125
     126= 1.0.8 =
     127Fast mode & Manual testing mode added
     128
    121129= 1.0.7 =
    122130UX & Previewing version improvements
Note: See TracChangeset for help on using the changeset viewer.