Changeset 3442225
- Timestamp:
- 01/19/2026 05:48:32 AM (4 weeks ago)
- Location:
- abtestkit/trunk
- Files:
-
- 4 edited
-
abtestkit.php (modified) (30 diffs)
-
assets/js/dashboard.js (modified) (4 diffs)
-
assets/js/pt-wizard.js (modified) (6 diffs)
-
readme.txt (modified) (5 diffs)
Legend:
- Unmodified
- Added
- Removed
-
abtestkit/trunk/abtestkit.php
r3425058 r3442225 4 4 * Plugin URI: https://wordpress.org/plugins/abtestkit 5 5 * Description: Split testing for WooCommerce & WordPress, compatible with all page builders, themes & caching plugins. 6 * Version: 1.0. 76 * Version: 1.0.8 7 7 * Author: abtestkit 8 8 * License: GPL-2.0-or-later … … 92 92 return [ 93 93 'plugin' => 'abtestkit', 94 'version' => '1.0. 7',94 'version' => '1.0.8', 95 95 'site' => md5( home_url() ), // anonymous hash 96 96 'wp' => get_bloginfo( 'version' ), … … 225 225 plugins_url( 'assets/js/onboarding.js', __FILE__ ), 226 226 array( 'wp-element', 'wp-components', 'wp-api-fetch' ), 227 '1.0. 7',227 '1.0.8', 228 228 true 229 229 ); … … 521 521 $split = max( 0, min( 100, (int) ( $req->get_param( 'split' ) ?? 50 ) ) ); 522 522 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; } 528 557 } 529 558 … … 684 713 'status' => $start ? 'running' : 'draft', 685 714 '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, 687 719 'cookie_ttl_days' => 30, 688 720 'started_at' => $start ? time() : 0, 689 721 'finished_at' => 0, 722 'paused_at' => 0, 723 'paused_total' => 0, 690 724 // Mark what kind of test this is 691 725 'kind' => ( $post_type === 'product' ) … … 693 727 : ( $post_type === 'post' ? 'post' : 'page' ), 694 728 ]; 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_raw702 : 5;703 $test['min_conversions'] = (int) $min_conversions;704 729 705 730 // Attach overrides for product tests so Woo filters can read them. … … 784 809 785 810 foreach ( $tests as $test ) { 811 $test_id = isset( $test['id'] ) ? (string) $test['id'] : ''; 786 812 $control_id = isset( $test['control_id'] ) ? (int) $test['control_id'] : 0; 787 813 $variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0; 788 814 $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 } 789 827 790 828 // Base admin previews are different for product tests vs page/post tests. … … 833 871 834 872 $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, 843 885 // 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, 845 887 // 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, 848 890 ]; 849 891 } … … 1895 1937 } 1896 1938 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) 1898 1940 $content = get_post_field('post_content', $post_id ); 1899 1941 $blocks = parse_blocks( $content ); … … 1915 1957 $scan( $blocks ); 1916 1958 1917 // Get current stats for this test1959 // Get current stats for this test 1918 1960 $stats_resp = abtestkit_handle_stats( $request ); 1919 1961 $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 1924 1965 $impA = (int) ($stats['A']['impressions'] ?? 0); 1925 1966 $clkA = (int) ($stats['A']['clicks'] ?? 0); … … 1927 1968 $clkB = (int) ($stats['B']['clicks'] ?? 0); 1928 1969 1929 // Per-test minimum conversions before declaring a winner (defaults to 5). 1970 // Defaults (legacy) 1971 $decisionMode = 'auto'; 1972 $minImpressions = 50; 1930 1973 $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) 1942 1997 $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 1945 2001 ); 1946 2002 $cached_eval = wp_cache_get( $eval_cache_key, 'abtestkit_eval' ); … … 1949 2005 } 1950 2006 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 1968 2019 $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 $minImpressions1978 ),1979 ]);1980 }1981 1982 // Minimum total conversions (A + B) required before Bayesian evaluation1983 // can declare a winner.1984 2020 $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; 2000 2045 $alphaA = $priorN/2 + $clkA; 2001 2046 $betaA = $priorN/2 + max(0, $impA - $clkA); … … 2018 2063 $probA = $countA / $numSamples; 2019 2064 $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 2020 2080 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) 2039 2086 $winner = ''; 2040 if ( $ totalConversions >= $minConversions) {2041 if ( $probA > 0.95&& $ciLower > 0 ) {2087 if ( $decisionMode !== 'manual' ) { 2088 if ( $probA > $confidence && $ciLower > 0 ) { 2042 2089 $winner = 'A'; 2043 } elseif ( $probB > 0.95&& $ciUpper < 0 ) {2090 } elseif ( $probB > $confidence && $ciUpper < 0 ) { 2044 2091 $winner = 'B'; 2045 2092 } … … 2053 2100 'winner' => $winner 2054 2101 ]; 2055 // cache for a short time; cache key includes the exact counts so it auto-invalidates 2102 2056 2103 wp_cache_set( $eval_cache_key, $result, 'abtestkit_eval', 60 ); 2057 2104 return rest_ensure_response( $result ); … … 2482 2529 } 2483 2530 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 2484 2553 // Safety: if a "running" test has lost both page references, pause it. 2485 2554 if ( $t['status'] === 'running' ) { … … 2493 2562 } 2494 2563 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; } 2501 2610 } 2502 2611 } … … 3063 3172 'started_at' => 0, 3064 3173 'finished_at' => 0, 3174 'paused_at' => 0, 3175 'paused_total' => 0, 3065 3176 'kind' => ( $post_type === 'product' ) ? 'product' : 'page', 3066 3177 ]; … … 3101 3212 exit; 3102 3213 } 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'; 3105 3235 abtestkit_pt_put( $test ); 3106 3236 break; 3107 3237 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 3108 3246 $test['status'] = 'paused'; 3109 3247 abtestkit_pt_put( $test ); … … 3134 3272 $kind = isset( $test['kind'] ) ? (string) $test['kind'] : 'page'; 3135 3273 3274 // Snapshot now (before we overwrite A / commit overrides / trash B). 3275 abtestkit_pt_snapshot_completed_test( $test ); 3276 3136 3277 if ( $kind === 'product' ) { 3137 3278 $product_id = (int) $test['control_id']; … … 3187 3328 // End the test: mark as complete so it disappears from "running" actions 3188 3329 // 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 ); 3192 3334 break; 3193 3335 } … … 3269 3411 3270 3412 /** 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 */ 3417 function 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 3456 function 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 3475 function 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 */ 3526 function 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 3271 3573 function 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). 3272 3585 global $wpdb; 3273 3586 … … 3275 3588 $variant_id = isset( $test['variant_id'] ) ? (int) $test['variant_id'] : 0; 3276 3589 3277 // Safety: if somehow no control page, just return zeros.3278 3590 if ( $control_id <= 0 ) { 3279 3591 return [ … … 3283 3595 } 3284 3596 3285 // Direct read from a custom log table (no core abstraction available).3286 3597 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching 3287 3598 $rows = $wpdb->get_results( 3288 3599 $wpdb->prepare( 3289 3600 'SELECT variant, event_type, COUNT(*) AS c ' . 3290 // Custom table name is fixed (prefix + constant suffix). Safe to concatenate.3291 3601 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared 3292 3602 'FROM `' . ABTESTKIT_EVENTS_TABLE . '` ' . … … 3302 3612 3303 3613 $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 ], 3312 3616 ]; 3313 3617 … … 3324 3628 return $out; 3325 3629 } 3630 3326 3631 3327 3632 /** … … 3334 3639 */ 3335 3640 function abtestkit_pt_winner_for_dashboard( array $test ) : string { 3641 // If we already locked a winner, always show it. 3336 3642 $locked = isset( $test['winner'] ) ? sanitize_text_field( (string) $test['winner'] ) : ''; 3337 3643 if ( in_array( $locked, [ 'A', 'B' ], true ) ) { 3338 3644 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 ''; 3339 3652 } 3340 3653 … … 3368 3681 return; 3369 3682 } 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 3370 3690 $already = isset( $test['winner'] ) ? sanitize_text_field( (string) $test['winner'] ) : ''; 3371 3691 if ( in_array( $already, [ 'A', 'B' ], true ) ) { … … 3394 3714 $test['status'] = 'winner'; // Paused automatically, but with an explicit Winner state. 3395 3715 3716 abtestkit_pt_snapshot_completed_test( $test ); 3396 3717 abtestkit_pt_put( $test ); 3397 3718 } 3398 3399 3719 3400 3720 // Dashboard Render // … … 3448 3768 plugins_url( 'assets/js/pt-wizard.js', __FILE__ ), 3449 3769 [ 'wp-element', 'wp-components', 'wp-api-fetch', 'wp-editor' ], 3450 '1.0. 7',3770 '1.0.8', 3451 3771 true 3452 3772 ); … … 3471 3791 plugins_url( 'assets/js/admin-list-guard.js', __FILE__ ), 3472 3792 [ 'jquery' ], 3473 ( defined( 'ABTESTKIT_VERSION' ) ? ABTESTKIT_VERSION : '1.0. 7' ),3793 ( defined( 'ABTESTKIT_VERSION' ) ? ABTESTKIT_VERSION : '1.0.8' ), 3474 3794 true 3475 3795 ); -
abtestkit/trunk/assets/js/dashboard.js
r3425058 r3442225 251 251 'Impressions are the number of times each version was shown to visitors.', 252 252 '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.', 254 255 ]) 255 256 ] … … 423 424 }; 424 425 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 425 456 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 } 427 465 } else if (isWinner) { 428 466 actionButtons.push( … … 673 711 }, 674 712 }, 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') 676 718 ), 677 719 h( … … 775 817 }) 776 818 ) 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 ] 777 875 ) 778 876 ); -
abtestkit/trunk/assets/js/pt-wizard.js
r3425058 r3442225 512 512 513 513 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 }) => 516 516 h( 517 517 "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 ] 520 554 ); 521 555 … … 751 785 752 786 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 754 795 const [productPreviewToken, setProductPreviewToken] = useState(""); 755 796 const [links, setLinks] = useState(""); // comma-separated … … 1037 1078 1038 1079 // 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 1039 1090 const payload = { 1040 1091 control_id: pageA.id, … … 1044 1095 split: 50, 1045 1096 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 1047 1104 links: links.split(",").map((s) => s.trim()).filter(Boolean), 1048 1105 }; … … 2566 2623 display: "flex", 2567 2624 flexDirection: "column", 2568 alignItems: "flex- end",2625 alignItems: "flex-start", // left-align Version B details 2569 2626 lineHeight: "1.4", 2570 2627 gap: "2px", … … 2659 2716 { style: { marginTop: 12 } }, 2660 2717 h(SelectControl, { 2661 label: " Minimum conversions needed",2662 value: String( minConversions),2718 label: "AUTO TEST RULE", 2719 value: String(decisionRule), 2663 2720 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" }, 2667 2725 ], 2668 onChange: (v) => set MinConversions(v),2726 onChange: (v) => setDecisionRule(v), 2669 2727 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.", 2671 2731 }) 2672 2732 ), -
abtestkit/trunk/readme.txt
r3425058 r3442225 5 5 Tested up to: 6.9 6 6 Requires PHP: 7.4 7 Stable tag: 1.0. 77 Stable tag: 1.0.8 8 8 License: GPL-2.0-or-later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 15 15 = The simplest way to A/B test in WordPress = 16 16 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. 18 18 Create full-page split tests in seconds, track performance automatically, and apply the winner with one click. 19 19 … … 28 28 * **No analysis needed** - abtestkit tracks impressions & clicks, then automatically declares the winning variant with 95% confidence. 29 29 30 abtestkit is n't just another plugin. It'sa growth tool that helps you experiment, learn, and keep moving forward.30 abtestkit is a growth tool that helps you experiment, learn, and keep moving forward. 31 31 32 32 ### Use cases … … 77 77 == Changelog == 78 78 79 = 1.0.8 = 80 * Manual tests added 81 * Fast testing added 82 * UI on Dashboard and Test Creation Wizard improved 83 79 84 = 1.0.7 = 80 85 * Previewing version improvements … … 119 124 == Upgrade Notice == 120 125 126 = 1.0.8 = 127 Fast mode & Manual testing mode added 128 121 129 = 1.0.7 = 122 130 UX & Previewing version improvements
Note: See TracChangeset
for help on using the changeset viewer.