diff --git a/includes/classes/class-rest-api.php b/includes/classes/class-rest-api.php index e364bfb81..579d719b3 100644 --- a/includes/classes/class-rest-api.php +++ b/includes/classes/class-rest-api.php @@ -874,6 +874,29 @@ private function get_summary_data( $post_id ) { 'warnings' => 0, 'ignored' => 0, 'readability' => 0, + 'regression' => [ + 'has_baseline' => false, + 'status' => 'stable', + 'delta' => [ + 'errors' => 0, + 'warnings' => 0, + 'contrast_errors' => 0, + 'passed_tests' => 0, + ], + 'scanned_at' => '', + ], + ]; + } elseif ( ! isset( $summary['regression'] ) || ! is_array( $summary['regression'] ) ) { + $summary['regression'] = [ + 'has_baseline' => false, + 'status' => 'stable', + 'delta' => [ + 'errors' => 0, + 'warnings' => 0, + 'contrast_errors' => 0, + 'passed_tests' => 0, + ], + 'scanned_at' => '', ]; } diff --git a/includes/classes/class-summary-generator.php b/includes/classes/class-summary-generator.php index ac64a4d7e..d3bd92dcc 100644 --- a/includes/classes/class-summary-generator.php +++ b/includes/classes/class-summary-generator.php @@ -58,8 +58,9 @@ public function __construct( $post_id ) { public function generate_summary() { global $wpdb; - $table_name = edac_get_valid_table_name( $wpdb->prefix . 'accessibility_checker' ); - $summary = []; + $table_name = edac_get_valid_table_name( $wpdb->prefix . 'accessibility_checker' ); + $summary = []; + $previous_summary = get_post_meta( $this->post_id, '_edac_summary', true ); if ( ! $table_name ) { return $summary; @@ -80,12 +81,96 @@ public function generate_summary() { $summary['content_grade'] = $this->calculate_content_grade(); $summary['readability'] = $this->get_readability( $summary ); $summary['simplified_summary'] = (bool) ( get_post_meta( $this->post_id, '_edac_simplified_summary', true ) ); + + // If issue counts haven't changed since the last save, preserve existing regression + // data to avoid a second generate_summary() call overwriting deltas with zeros. + if ( $this->issue_counts_match( $previous_summary, $summary ) && ! empty( $previous_summary['regression'] ) ) { + $summary['regression'] = $previous_summary['regression']; + } else { + $summary['regression'] = $this->build_regression_data( $previous_summary, $summary ); + } + $this->update_issue_density( $summary ); $this->save_summary_meta_data( $summary ); return $summary; } + /** + * Checks whether issue-related counts in the current summary match a previously saved summary. + * + * @param mixed $previous_summary The previously saved summary meta. + * @param array $current_summary The current summary data. + * + * @return bool True when all issue counts are identical. + */ + private function issue_counts_match( $previous_summary, array $current_summary ): bool { + if ( ! is_array( $previous_summary ) ) { + return false; + } + + $metrics = [ 'errors', 'warnings', 'contrast_errors', 'passed_tests', 'content_grade' ]; + + foreach ( $metrics as $metric ) { + if ( absint( $previous_summary[ $metric ] ?? 0 ) !== absint( $current_summary[ $metric ] ?? 0 ) ) { + return false; + } + } + + return true; + } + + /** + * Build regression/trend data by comparing the current summary with the previous scan summary. + * + * @param mixed $previous_summary The previously saved summary meta. + * @param array $current_summary The current summary data. + * + * @return array + */ + private function build_regression_data( $previous_summary, array $current_summary ) { + $metrics = [ + 'errors', + 'warnings', + 'contrast_errors', + 'passed_tests', + 'content_grade', + ]; + + $deltas = []; + foreach ( $metrics as $metric ) { + $current_value = absint( $current_summary[ $metric ] ?? 0 ); + $previous_value = is_array( $previous_summary ) ? absint( $previous_summary[ $metric ] ?? 0 ) : 0; + $deltas[ $metric ] = $current_value - $previous_value; + } + + $regression_score = max( 0, $deltas['errors'] ) + max( 0, $deltas['warnings'] ) + max( 0, $deltas['contrast_errors'] ); + + $status = 'stable'; + if ( $regression_score >= 5 || $deltas['passed_tests'] <= -10 ) { + $status = 'declining'; + } elseif ( $regression_score > 0 || $deltas['passed_tests'] < 0 ) { + $status = 'watch'; + } elseif ( $deltas['errors'] < 0 || $deltas['warnings'] < 0 || $deltas['contrast_errors'] < 0 || $deltas['passed_tests'] > 0 ) { + $status = 'improving'; + } + + $has_baseline = is_array( $previous_summary ) && ! empty( $previous_summary ); + + return [ + 'has_baseline' => (bool) $has_baseline, + 'status' => sanitize_key( $status ), + 'delta' => [ + 'errors' => intval( $deltas['errors'] ), + 'warnings' => intval( $deltas['warnings'] ), + 'contrast_errors' => intval( $deltas['contrast_errors'] ), + 'passed_tests' => intval( $deltas['passed_tests'] ), + 'content_grade' => intval( $deltas['content_grade'] ), + ], + 'scanned_at' => current_time( 'mysql', true ), + ]; + } + /** * Calculates the percentage of passed tests based on the provided rules. * This method queries the database to find which rules have not been violated (passed) for @@ -330,6 +415,51 @@ private function save_summary_meta_data( $summary ) { update_post_meta( $this->post_id, '_edac_summary_warnings', absint( $summary['warnings'] ) ); update_post_meta( $this->post_id, '_edac_summary_ignored', absint( $summary['ignored'] ) ); update_post_meta( $this->post_id, '_edac_summary_contrast_errors', absint( $summary['contrast_errors'] ) ); + $this->update_summary_history( $summary ); + } + + /** + * Stores compact summary scan history used for trend/regression messaging. + * + * @param array $summary The latest summary. + * + * @return void + */ + private function update_summary_history( array $summary ) { + $history = get_post_meta( $this->post_id, '_edac_summary_history', true ); + $history = is_array( $history ) ? $history : []; + $timestamp = current_time( 'mysql', true ); + + $new_entry = [ + 'scanned_at' => $timestamp, + 'errors' => absint( $summary['errors'] ?? 0 ), + 'warnings' => absint( $summary['warnings'] ?? 0 ), + 'contrast_errors' => absint( $summary['contrast_errors'] ?? 0 ), + 'passed_tests' => absint( $summary['passed_tests'] ?? 0 ), + 'content_grade' => absint( $summary['content_grade'] ?? 0 ), + ]; + + // Skip duplicate entries when generate_summary() is called multiple times per scan. + if ( ! empty( $history ) ) { + $last = end( $history ); + if ( + absint( $last['errors'] ?? -1 ) === $new_entry['errors'] && + absint( $last['warnings'] ?? -1 ) === $new_entry['warnings'] && + absint( $last['contrast_errors'] ?? -1 ) === $new_entry['contrast_errors'] && + absint( $last['passed_tests'] ?? -1 ) === $new_entry['passed_tests'] && + absint( $last['content_grade'] ?? -1 ) === $new_entry['content_grade'] + ) { + return; + } + } + + $history[] = $new_entry; + + if ( count( $history ) > 10 ) { + $history = array_slice( $history, -10 ); + } + + update_post_meta( $this->post_id, '_edac_summary_history', $history ); } /** @@ -351,6 +481,18 @@ private function sanitize_summary_meta_data( array $summary ): array { 'content_grade' => absint( $summary['content_grade'] ?? 0 ), 'readability' => sanitize_text_field( $summary['readability'] ?? '' ), 'simplified_summary' => filter_var( $summary['simplified_summary'] ?? false, FILTER_VALIDATE_BOOLEAN ), + 'regression' => [ + 'has_baseline' => filter_var( $summary['regression']['has_baseline'] ?? false, FILTER_VALIDATE_BOOLEAN ), + 'status' => sanitize_key( $summary['regression']['status'] ?? 'stable' ), + 'delta' => [ + 'errors' => intval( $summary['regression']['delta']['errors'] ?? 0 ), + 'warnings' => intval( $summary['regression']['delta']['warnings'] ?? 0 ), + 'contrast_errors' => intval( $summary['regression']['delta']['contrast_errors'] ?? 0 ), + 'passed_tests' => intval( $summary['regression']['delta']['passed_tests'] ?? 0 ), + 'content_grade' => intval( $summary['regression']['delta']['content_grade'] ?? 0 ), + ], + 'scanned_at' => sanitize_text_field( $summary['regression']['scanned_at'] ?? '' ), + ], ]; } } diff --git a/package-lock.json b/package-lock.json index 16537c244..059f80146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "accessibility-checker", - "version": "1.38.0", + "version": "1.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "accessibility-checker", - "version": "1.38.0", + "version": "1.39.0", "hasInstallScript": true, "license": "GPL-2.0+", "devDependencies": { diff --git a/src/sidebar/components/Panels/AccessibilityStatus.js b/src/sidebar/components/Panels/AccessibilityStatus.js index c329849e7..ac693b288 100644 --- a/src/sidebar/components/Panels/AccessibilityStatus.js +++ b/src/sidebar/components/Panels/AccessibilityStatus.js @@ -69,6 +69,8 @@ const AccessibilityStatus = () => { // Extract data from store const summary = data?.summary || {}; const readability = data?.readability || {}; + const regression = summary.regression || {}; + const regressionDelta = regression.delta || {}; const coveragePercent = summary.passed_tests || 0; const problems = ( summary.errors || 0 ) + ( summary.contrast_errors || 0 ); @@ -78,6 +80,47 @@ const AccessibilityStatus = () => { const postGradeReadable = readability.post_grade_readability || ''; const needsSummary = !! readability.post_grade_failed; const hasSummary = !! readability.simplified_summary; + const hasRegressionBaseline = !! regression.has_baseline; + const regressionStatus = regression.status || 'stable'; + const problemsDelta = + ( regressionDelta.errors || 0 ) + ( regressionDelta.contrast_errors || 0 ); + const warningDelta = regressionDelta.warnings || 0; + const passedDelta = regressionDelta.passed_tests || 0; + const readingLevelDelta = regressionDelta.content_grade || 0; + + const renderRegressionMessage = useCallback( + ( delta, noun, invertMeaning = false ) => { + if ( ! hasRegressionBaseline ) { + return __( 'Run another scan to start tracking trend changes.', 'accessibility-checker' ); + } + + if ( delta === 0 ) { + return sprintf( __( 'No change in %s since the last scan.', 'accessibility-checker' ), noun ); + } + + const increase = delta > 0; + const absDelta = Math.abs( delta ); + const directionWord = increase ? __( 'up', 'accessibility-checker' ) : __( 'down', 'accessibility-checker' ); + const impactWord = ( increase && ! invertMeaning ) || ( ! increase && invertMeaning ) + ? __( 'Regression', 'accessibility-checker' ) + : __( 'Improvement', 'accessibility-checker' ); + + return sprintf( + // translators: 1: regression/improvement label, 2: magnitude, 3: metric label, 4: up/down. + __( '%1$s: %2$d %3$s %4$s since last scan.', 'accessibility-checker' ), + impactWord, + absDelta, + noun, + directionWord, + ); + }, + [ hasRegressionBaseline ], + ); + + const retentionMessage = + regressionStatus === 'declining' + ? __( 'Accessibility is declining. If you stop scanning, this drift may go unnoticed until users are blocked.', 'accessibility-checker' ) + : ''; // Determine status icon based on errors and warnings let statusIconName = 'check'; @@ -206,7 +249,9 @@ const AccessibilityStatus = () => {
{ retentionMessage }
+