From 570b047e510f324835f86f946e539729a79b70b9 Mon Sep 17 00:00:00 2001
From: David Stinemetze <davidstinemetze@gmail.com>
Date: Sun, 24 Mar 2019 23:06:33 -0500
Subject: [PATCH] Issue #3041509 by WidgetsBurritos: Job Summary Report

---
 accessibility_scanner.info.yml                     |   2 +-
 accessibility_scanner.install                      | 177 +++++++++++++++++++++
 accessibility_scanner.libraries.yml                |  11 ++
 accessibility_scanner.module                       |  36 +++++
 accessibility_scanner.routing.yml                  |  11 ++
 accessibility_scanner.services.yml                 |  11 ++
 css/achecker.css                                   |  26 +++
 js/gchart.js                                       |  88 ++++++++++
 src/Controller/AcheckerHistoryController.php       |  71 +++++++++
 .../WebPageArchiveEventSubscriber.php              | 115 +++++++++++++
 src/Plugin/views/area/AcheckerResult.php           |  89 +++++++++++
 src/Sql/AcheckerSummaryStorage.php                 | 110 +++++++++++++
 src/Sql/AcheckerSummaryStorageInterface.php        |  38 +++++
 templates/wpa-achecker-full-report.html.twig       |   2 +-
 templates/wpa-achecker-history.html.twig           |  45 ++++++
 templates/wpa-achecker-preview.html.twig           |   9 +-
 templates/wpa-achecker-summary.html.twig           |  45 ++++++
 tests/src/Functional/AcheckerEndToEndTest.php      |  26 ++-
 18 files changed, 899 insertions(+), 13 deletions(-)
 create mode 100644 accessibility_scanner.install
 create mode 100644 accessibility_scanner.routing.yml
 create mode 100644 accessibility_scanner.services.yml
 create mode 100644 js/gchart.js
 create mode 100644 src/Controller/AcheckerHistoryController.php
 create mode 100644 src/EventSubscriber/WebPageArchiveEventSubscriber.php
 create mode 100644 src/Plugin/views/area/AcheckerResult.php
 create mode 100644 src/Sql/AcheckerSummaryStorage.php
 create mode 100644 src/Sql/AcheckerSummaryStorageInterface.php
 create mode 100644 templates/wpa-achecker-history.html.twig
 create mode 100644 templates/wpa-achecker-summary.html.twig

diff --git a/accessibility_scanner.info.yml b/accessibility_scanner.info.yml
index 7c39902..42df444 100644
--- a/accessibility_scanner.info.yml
+++ b/accessibility_scanner.info.yml
@@ -5,4 +5,4 @@ package: Web Page Archive
 core: 8.x
 dependencies:
   - key:key
-  - web_page_archive:web_page_archive
+  - web_page_archive:web_page_archive (>=8.x-2.7)
diff --git a/accessibility_scanner.install b/accessibility_scanner.install
new file mode 100644
index 0000000..237260c
--- /dev/null
+++ b/accessibility_scanner.install
@@ -0,0 +1,177 @@
+<?php
+
+/**
+ * @file
+ * Install commands for accessibility_scanner.
+ */
+
+use Drupal\Core\Database\Database;
+
+/**
+ * Implements hook_schema().
+ */
+function accessibility_scanner_schema() {
+  $schema['wpa_achecker_summary'] = [
+    'description' => 'Summary of capture jobs.',
+    'fields' => [
+      'entity_id' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Maps to {web_page_archive_run_revision}.id.',
+      ],
+      'vid' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Maps to {web_page_archive_run_revision}.vid.',
+      ],
+      'total' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total URLs captured.',
+      ],
+      'pass' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total passing URLs captured.',
+      ],
+      'fail' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total failing URLs captured.',
+      ],
+      'num_of_errors' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total number of errors detected.',
+      ],
+      'num_of_likely_problems' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total number of likely problems detected.',
+      ],
+      'num_of_potential_problems' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total number of potential problems detected.',
+      ],
+      'guidelines' => [
+        'type' => 'varchar_ascii',
+        'length' => 1023,
+        'not null' => TRUE,
+        'default' => '',
+        'description' => 'Serializes list of applied guidelines',
+      ],
+      'timestamp' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Timestamp when the capture was completed.',
+      ],
+    ],
+    'primary key' => ['vid'],
+  ];
+
+  return $schema;
+}
+
+/**
+ * Issue #3041509: Creates job summary table.
+ */
+function accessibility_scanner_update_8001() {
+  $spec = [
+    'description' => 'Summary of capture jobs.',
+    'fields' => [
+      'entity_id' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Maps to {web_page_archive_run_revision}.id.',
+      ],
+      'vid' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Maps to {web_page_archive_run_revision}.vid.',
+      ],
+      'total' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total URLs captured.',
+      ],
+      'pass' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total passing URLs captured.',
+      ],
+      'fail' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total failing URLs captured.',
+      ],
+      'num_of_errors' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total number of errors detected.',
+      ],
+      'num_of_likely_problems' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total number of likely problems detected.',
+      ],
+      'num_of_potential_problems' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Total number of potential problems detected.',
+      ],
+      'guidelines' => [
+        'type' => 'varchar_ascii',
+        'length' => 1023,
+        'not null' => TRUE,
+        'default' => '',
+        'description' => 'Serializes list of applied guidelines',
+      ],
+      'timestamp' => [
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+        'default' => 0,
+        'description' => 'Timestamp when the capture was completed.',
+      ],
+    ],
+    'primary key' => ['vid'],
+  ];
+
+  $schema = Database::getConnection()->schema();
+  $schema->createTable('wpa_achecker_summary', $spec);
+}
diff --git a/accessibility_scanner.libraries.yml b/accessibility_scanner.libraries.yml
index 351ed56..2b34419 100644
--- a/accessibility_scanner.libraries.yml
+++ b/accessibility_scanner.libraries.yml
@@ -10,3 +10,14 @@ detail-filter:
   dependencies:
     - core/jquery
     - core/jquery.once
+google-charts:
+  remote: https://www.gstatic.com/charts/loader.js
+  license:
+    name: 'Apache 2.0'
+    url: 'http://www.apache.org/licenses/LICENSE-2.0'
+    gpl-compatible: false
+  dependencies:
+    - core/jquery
+  js:
+    https://www.gstatic.com/charts/loader.js: { type: external, minified: true }
+    js/gchart.js: {}
diff --git a/accessibility_scanner.module b/accessibility_scanner.module
index 570dd77..3139018 100644
--- a/accessibility_scanner.module
+++ b/accessibility_scanner.module
@@ -8,8 +8,44 @@ function accessibility_scanner_theme($existing, $type, $theme, $path) {
     'wpa-achecker-full-report' => [
       'variables' => ['summary' => NULL, 'results' => NULL],
     ],
+    'wpa-achecker-history' => [
+      'variables' => ['results' => NULL],
+    ],
     'wpa-achecker-preview' => [
       'variables' => ['summary' => NULL, 'url' => NULL, 'view_button' => NULL],
     ],
+    'wpa-achecker-summary' => [
+      'variables' => ['result' => NULL, 'trend_button' => NULL],
+    ],
+  ];
+}
+
+/**
+ * Implements hook_views_pre_view().
+ */
+function accessibility_scanner_views_pre_view($view, $display_id, array &$args) {
+  if ($view->id() == 'web_page_archive_individual' && $display_id == 'individual_run_page' && isset($args[0]) && is_numeric($args[0])) {
+    $item = [
+      'id' => 'achecker_result',
+      'table' => 'views',
+      'field' => 'achecker_result',
+      'plugin_id' => 'achecker_result',
+    ];
+
+    $view->setHandler($display_id, 'header', 'achecker_result', $item);
+  }
+}
+
+/**
+ * Implements hook_views_data().
+ */
+function accessibility_scanner_views_data() {
+  $data['views']['achecker_result'] = [
+    'title' => t('AChecker Summary Results'),
+    'help' => t('Shows achecker result summary'),
+    'area' => [
+      'id' => 'achecker_result',
+    ],
   ];
+  return $data;
 }
diff --git a/accessibility_scanner.routing.yml b/accessibility_scanner.routing.yml
new file mode 100644
index 0000000..42c768e
--- /dev/null
+++ b/accessibility_scanner.routing.yml
@@ -0,0 +1,11 @@
+entity.web_page_archive.achecker_history:
+  path: 'admin/config/system/web-page-archive/jobs/{web_page_archive}/achecker-history'
+  defaults:
+    _controller: '\Drupal\accessibility_scanner\Controller\AcheckerHistoryController::historyContent'
+    _title_callback: '\Drupal\accessibility_scanner\Controller\AcheckerHistoryController::historyTitle'
+  options:
+    parameters:
+      web_page_archive:
+        type: entity:web_page_archive
+  requirements:
+    _permission: 'view web page archive results'
diff --git a/accessibility_scanner.services.yml b/accessibility_scanner.services.yml
new file mode 100644
index 0000000..d42da6a
--- /dev/null
+++ b/accessibility_scanner.services.yml
@@ -0,0 +1,11 @@
+services:
+  accessibility_scanner.event_subscriber:
+    arguments: ['@accessibility_scanner.achecker_summary_storage']
+    class: Drupal\accessibility_scanner\EventSubscriber\WebPageArchiveEventSubscriber
+    tags:
+      - { name: event_subscriber }
+  accessibility_scanner.achecker_summary_storage:
+    arguments: ['@database', '@datetime.time']
+    class: Drupal\accessibility_scanner\Sql\AcheckerSummaryStorage
+    tags:
+      - { name: storage }
diff --git a/css/achecker.css b/css/achecker.css
index 7309ad0..cf3f739 100644
--- a/css/achecker.css
+++ b/css/achecker.css
@@ -101,3 +101,29 @@
 .achecker-clear {
   clear: both;
 }
+
+.achecker-runSummary {
+  background-color: #f8f8f8;
+  margin: 5px 0;
+  padding: 1px 5px 5px;
+}
+
+.achecker-runSummaryColumn {
+  float: left;
+  max-width: 33%;
+  padding: 0 15px;
+}
+
+.achecker-runSummaryList {
+  margin: 0;
+  padding: 0 20px;
+}
+
+.achecker-summaryDetails {
+  float: left;
+  width: 25%;
+}
+.achecker-summaryChart {
+  float: right;
+  width: 72%;
+}
diff --git a/js/gchart.js b/js/gchart.js
new file mode 100644
index 0000000..2dd883d
--- /dev/null
+++ b/js/gchart.js
@@ -0,0 +1,88 @@
+/**
+ * @file
+ * Provides some very basic filtering functionality for achecker results.
+ */
+
+(function ($, Drupal, drupalSettings) {
+  'use strict';
+
+  Drupal.accessibilityScanner = Drupal.accessibilityScanner || {};
+
+  Drupal.behaviors.accessibilityScannerGoogleChart = {
+    attach: function attach(context) {
+      google.charts.load('current', {packages: ['corechart', 'line']});
+      google.charts.setOnLoadCallback(Drupal.accessibilityScanner.drawCharts);
+    }
+  };
+
+  /**
+   * Draws all report tables.
+   */
+  Drupal.accessibilityScanner.drawCharts = function () {
+    Drupal.accessibilityScanner.drawPassFailChart();
+    Drupal.accessibilityScanner.drawProblemChart();
+  }
+
+  /**
+   * Draws the pass/fail table.
+   */
+  Drupal.accessibilityScanner.drawPassFailChart = function () {
+    var data = new google.visualization.DataTable();
+    data.addColumn('date', 'X');
+    data.addColumn('number', Drupal.t('Total'));
+    data.addColumn('number', Drupal.t('Passing'));
+    data.addColumn('number', Drupal.t('Failing'));
+
+    var problemRows = [];
+    for (var i in drupalSettings.acheckerResults) {
+      problemRows.push([
+        new Date(drupalSettings.acheckerResults[i].timestamp * 1000),
+        +drupalSettings.acheckerResults[i].total,
+        +drupalSettings.acheckerResults[i].pass,
+        +drupalSettings.acheckerResults[i].fail,
+      ]);
+    }
+
+    data.addRows(problemRows);
+
+    var options = {
+      hAxis: { title: Drupal.t('Time') },
+      vAxis: { title: Drupal.t('URLs') },
+    };
+
+    var chart = new google.visualization.LineChart(document.getElementById('achecker_pass_fail_chart'));
+    chart.draw(data, options);
+  }
+
+  /**
+   * Draws the problem table.
+   */
+  Drupal.accessibilityScanner.drawProblemChart = function () {
+    var data = new google.visualization.DataTable();
+    data.addColumn('date', 'X');
+    data.addColumn('number', Drupal.t('Errors'));
+    data.addColumn('number', Drupal.t('Likely Problems'));
+    data.addColumn('number', Drupal.t('Potential Problems'));
+
+    var problemRows = [];
+    for (var i in drupalSettings.acheckerResults) {
+      problemRows.push([
+        new Date(drupalSettings.acheckerResults[i].timestamp * 1000),
+        +drupalSettings.acheckerResults[i].num_of_errors,
+        +drupalSettings.acheckerResults[i].num_of_likely_problems,
+        +drupalSettings.acheckerResults[i].num_of_potential_problems,
+      ]);
+    }
+
+    data.addRows(problemRows);
+
+    var options = {
+      hAxis: { title: Drupal.t('Time') },
+      vAxis: { title: Drupal.t('Problems') },
+    };
+
+    var chart = new google.visualization.LineChart(document.getElementById('achecker_problem_chart'));
+    chart.draw(data, options);
+  }
+
+})(jQuery, Drupal, drupalSettings);
diff --git a/src/Controller/AcheckerHistoryController.php b/src/Controller/AcheckerHistoryController.php
new file mode 100644
index 0000000..f109f93
--- /dev/null
+++ b/src/Controller/AcheckerHistoryController.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\accessibility_scanner\Controller;
+
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\accessibility_scanner\Sql\AcheckerSummaryStorageInterface;
+use Drupal\web_page_archive\Entity\WebPageArchiveInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Class AcheckerHistoryController.
+ */
+class AcheckerHistoryController extends ControllerBase {
+
+  /**
+   * Constructs a new AcheckerHistoryController.
+   *
+   * @param Drupal\accessibility_scanner\Sql\AcheckerSummaryStorageInterface $storage
+   *   The achecker storage service.
+   */
+  public function __construct(AcheckerSummaryStorageInterface $storage) {
+    $this->storage = $storage;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('accessibility_scanner.achecker_summary_storage')
+    );
+  }
+
+  /**
+   * Returns render array for the history content.
+   *
+   * @param \Drupal\web_page_archive\Entity\WebPageArchiveInterface $web_page_archive
+   *   A web page archive config entity.
+   *
+   * @return array
+   *   Render array for the history content.
+   */
+  public function historyContent(WebPageArchiveInterface $web_page_archive) {
+    $run_entity = $web_page_archive->getRunEntity();
+    $results = $this->storage->getResultsByJobId($run_entity->id());
+
+    return [
+      '#theme' => 'wpa-achecker-history',
+      '#results' => $results,
+      '#attached' => [
+        'drupalSettings' => [
+          'acheckerResults' => $results,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Returns title for the history route.
+   *
+   * @param \Drupal\web_page_archive\Entity\WebPageArchiveInterface $web_page_archive
+   *   A web page archive config entity.
+   *
+   * @return string
+   *   The title of for the history route.
+   */
+  public function historyTitle(WebPageArchiveInterface $web_page_archive) {
+    return $this->t('Accessibility History: @label', ['@label' => $web_page_archive->label()]);
+  }
+
+}
diff --git a/src/EventSubscriber/WebPageArchiveEventSubscriber.php b/src/EventSubscriber/WebPageArchiveEventSubscriber.php
new file mode 100644
index 0000000..0922358
--- /dev/null
+++ b/src/EventSubscriber/WebPageArchiveEventSubscriber.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace Drupal\accessibility_scanner\EventSubscriber;
+
+use Drupal\accessibility_scanner\Sql\AcheckerSummaryStorageInterface;
+use Drupal\web_page_archive\Entity\WebPageArchiveRunInterface;
+use Drupal\web_page_archive\Event\CaptureJobCompleteEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Subscribes to the web page archive events.
+ */
+class WebPageArchiveEventSubscriber implements EventSubscriberInterface {
+
+  /**
+   * Storage service.
+   *
+   * @var Drupal\accessibility_scanner\Sql\AcheckerSummaryStorageInterface
+   */
+  protected $acheckerStorage;
+
+  /**
+   * Constructs a new WebPageArchiveEventSubscriber instance().
+   *
+   * @param Drupal\accessibility_scanner\Sql\AcheckerSummaryStorageInterface $achecker_storage
+   *   Achecker storage service.
+   */
+  public function __construct(AcheckerSummaryStorageInterface $achecker_storage) {
+    $this->acheckerStorage = $achecker_storage;
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @return array
+   *   The event names to listen for, and the methods that should be executed.
+   */
+  public static function getSubscribedEvents() {
+    return [
+      CaptureJobCompleteEvent::EVENT_NAME => 'captureComplete',
+    ];
+  }
+
+  /**
+   * Checks if a run comparison entity has the achecker capture utility.
+   *
+   * @param \Drupal\web_page_archive\Entity\WebPageArchiveRunInterface $web_page_archive_run
+   *   The web page archive run entity to check for achecker.
+   */
+  private function hasAcheckerCaptureUtility(WebPageArchiveRunInterface $web_page_archive_run) {
+    foreach ($web_page_archive_run->getCaptureUtilities() as $capture_utility) {
+      $capture_utility_value = $capture_utility->getValue();
+      if (isset($capture_utility_value['wpa_achecker_capture'])) {
+        return TRUE;
+      }
+    }
+
+    return FALSE;
+  }
+
+  /**
+   * React to a capture job completing.
+   *
+   * @param \Drupal\web_page_archive\Event\CaptureJobCompleteEvent $event
+   *   Capture job completion event.
+   */
+  public function captureComplete(CaptureJobCompleteEvent $event) {
+    if (!$this->hasAcheckerCaptureUtility($event->runEntity)) {
+      return;
+    }
+
+    $result = [
+      'entity_id' => $event->runEntity->id(),
+      'vid' => $event->runEntity->getRevisionId(),
+      'total' => 0,
+      'pass' => 0,
+      'fail' => 0,
+      'num_of_errors' => 0,
+      'num_of_likely_problems' => 0,
+      'num_of_potential_problems' => 0,
+      'guidelines' => [],
+    ];
+    $captured = $event->runEntity->getCapturedArray();
+    foreach ($captured as $captured_item) {
+      $value = $captured_item->getValue();
+      if (empty($value['value'])) {
+        continue;
+      }
+      $capture_item_result = unserialize($value['value']);
+      if (!isset($capture_item_result['capture_response']) || $capture_item_result['capture_response']->getId() != 'wpa_achecker_capture_response') {
+        continue;
+      }
+      $summary = $capture_item_result['capture_response']->retrieveFileContents()->summary;
+      $result['total']++;
+      if ($summary->status == 'PASS') {
+        $result['pass']++;
+      }
+      else {
+        $result['fail']++;
+      }
+      $result['num_of_errors'] += intval($summary->NumOfErrors);
+      $result['num_of_likely_problems'] += intval($summary->NumOfLikelyProblems);
+      $result['num_of_potential_problems'] += intval($summary->NumOfPotentialProblems);
+      foreach ($summary->guidelines as $guideline) {
+        $guideline_str = (string) $guideline->guideline;
+        if (!isset($result['guidelines'][$guideline_str])) {
+          $result['guidelines'][$guideline_str] = $guideline_str;
+        }
+      }
+    }
+
+    $this->acheckerStorage->addResult($result);
+  }
+
+}
diff --git a/src/Plugin/views/area/AcheckerResult.php b/src/Plugin/views/area/AcheckerResult.php
new file mode 100644
index 0000000..5dc222c
--- /dev/null
+++ b/src/Plugin/views/area/AcheckerResult.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Drupal\accessibility_scanner\Plugin\views\area;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Url;
+use Drupal\accessibility_scanner\Sql\AcheckerSummaryStorageInterface;
+use Drupal\views\Plugin\views\area\AreaPluginBase;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Views area handler to display some configurable result summary.
+ *
+ * @ViewsArea("achecker_result")
+ */
+class AcheckerResult extends AreaPluginBase {
+
+  /**
+   * The entity_type.manager service.
+   *
+   * @var Drupal\Core\Entity\EntityTypeManager
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The achecker storage service.
+   *
+   * @var Drupal\accessibility_scanner\Sql\AcheckerSummaryStorageInterface
+   */
+  protected $storage;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
+    $instance->setStorage($container->get('accessibility_scanner.achecker_summary_storage'));
+    $instance->setEntityTypeManager($container->get('entity_type.manager'));
+    return $instance;
+  }
+
+  /**
+   * Sets the achecker storage service.
+   *
+   * @param \Drupal\accessibility_scanner\Sql\AcheckerSummaryStorageInterface $storage
+   *   The achecker storage service.
+   */
+  public function setStorage(AcheckerSummaryStorageInterface $storage) {
+    $this->storage = $storage;
+  }
+
+  /**
+   * Sets the entity type manager service.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager service.
+   */
+  public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render($empty = FALSE) {
+    if (isset($this->view->argument['vid_1'])) {
+      $run_id = intval($this->view->argument['vid_1']->getValue());
+      if ($result = $this->storage->getResult($run_id)) {
+        $run_entity = $this->entityTypeManager->getStorage('web_page_archive_run')->load($result['entity_id']);
+        $job_id = $run_entity->getConfigEntity()->id();
+        $route_params = ['web_page_archive' => $job_id];
+        return [
+          '#theme' => 'wpa-achecker-summary',
+          '#result' => $result,
+          '#trend_button' => [
+            '#type' => 'link',
+            '#url' => Url::fromRoute('entity.web_page_archive.achecker_history', $route_params),
+            '#title' => $this->t('View Historical Trends'),
+            '#attributes' => [
+              'class' => ['button'],
+            ],
+          ],
+        ];
+      }
+    }
+    return [];
+  }
+
+}
diff --git a/src/Sql/AcheckerSummaryStorage.php b/src/Sql/AcheckerSummaryStorage.php
new file mode 100644
index 0000000..0bf9532
--- /dev/null
+++ b/src/Sql/AcheckerSummaryStorage.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Drupal\accessibility_scanner\Sql;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Database\Connection;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Handles storage concerns for achecker summary.
+ */
+class AcheckerSummaryStorage implements AcheckerSummaryStorageInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Drupal database service.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $database;
+
+  /**
+   * Drupal time service.
+   *
+   * @var \Drupal\Component\Datetime\TimeInterface
+   */
+  protected $time;
+
+  /**
+   * Constructs a new AcheckerSummaryStorage object.
+   *
+   * @param \Drupal\Core\Database\Connection $database
+   *   Database connection service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   Drupal time service.
+   */
+  public function __construct(Connection $database, TimeInterface $time) {
+    $this->database = $database;
+    $this->time = $time;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addResult(array $result) {
+    $required_keys = [
+      'entity_id',
+      'vid',
+      'total',
+      'pass',
+      'fail',
+      'num_of_errors',
+      'num_of_likely_problems',
+      'num_of_potential_problems',
+      'guidelines',
+    ];
+    foreach ($required_keys as $required_key) {
+      if (!isset($result[$required_key])) {
+        throw new \Exception($this->t('@key is required', ['@key' => $required_key]));
+      }
+    }
+
+    $values = [
+      'entity_id' => (int) $result['entity_id'],
+      'vid' => (int) $result['vid'],
+      'total' => (int) $result['total'],
+      'pass' => (int) $result['pass'],
+      'fail' => (int) $result['fail'],
+      'num_of_errors' => (int) $result['num_of_errors'],
+      'num_of_likely_problems' => (int) $result['num_of_likely_problems'],
+      'num_of_potential_problems' => (int) $result['num_of_potential_problems'],
+      'guidelines' => serialize($result['guidelines']),
+      'timestamp' => $this->time->getRequestTime(),
+    ];
+
+    $this->database
+      ->upsert('wpa_achecker_summary')
+      ->key('vid')
+      ->fields(array_keys($values))
+      ->values(array_values($values))
+      ->execute();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getResult($vid) {
+    $result = $this->database
+      ->select('wpa_achecker_summary', 's')
+      ->fields('s')
+      ->condition('vid', (int) $vid)
+      ->execute()->fetchAssoc();
+    $result['guidelines'] = unserialize($result['guidelines']);
+    return $result;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getResultsByJobId($id) {
+    $result = $this->database
+      ->select('wpa_achecker_summary', 's')
+      ->fields('s')
+      ->condition('entity_id', (int) $id)
+      ->execute()->fetchAllAssoc('vid', \PDO::FETCH_ASSOC);
+    return $result;
+  }
+
+}
diff --git a/src/Sql/AcheckerSummaryStorageInterface.php b/src/Sql/AcheckerSummaryStorageInterface.php
new file mode 100644
index 0000000..4554cc8
--- /dev/null
+++ b/src/Sql/AcheckerSummaryStorageInterface.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Drupal\accessibility_scanner\Sql;
+
+/**
+ * Interface for achecker summary storage.
+ */
+interface AcheckerSummaryStorageInterface {
+
+  /**
+   * Sanitizes and adds a result based on the specified row data.
+   *
+   * @param array $row
+   *   An associative array containing necessary row data.
+   */
+  public function addResult(array $row);
+
+  /**
+   * Retrieves an individual result set for the specified run id.
+   *
+   * This unserializes guidelines automatically.
+   *
+   * @param int $vid
+   *   The web_page_archive_run revision id.
+   */
+  public function getResult($vid);
+
+  /**
+   * Retrieves all results for the specified job id.
+   *
+   * Unlike getResult(), this does not unserialize guidelines.
+   *
+   * @param int $id
+   *   The web_page_archive_run entity id.
+   */
+  public function getResultsByJobId($id);
+
+}
diff --git a/templates/wpa-achecker-full-report.html.twig b/templates/wpa-achecker-full-report.html.twig
index 2fe8eea..19aec46 100644
--- a/templates/wpa-achecker-full-report.html.twig
+++ b/templates/wpa-achecker-full-report.html.twig
@@ -1,7 +1,7 @@
 {#
 /**
  * @file
- * Template file the web page archive before/after screenshot comparison.
+ * Template file for run full report.
  *
  * Available variables:
  * - summary: Object containing summary results.
diff --git a/templates/wpa-achecker-history.html.twig b/templates/wpa-achecker-history.html.twig
new file mode 100644
index 0000000..1db6dfd
--- /dev/null
+++ b/templates/wpa-achecker-history.html.twig
@@ -0,0 +1,45 @@
+{#
+/**
+ * @file
+ * Template file for achecker history.
+ *
+ * Available variables:
+ * - attributes: Object containing attributes.
+ */
+#}
+
+{{ attach_library('accessibility_scanner/achecker') }}
+{{ attach_library('accessibility_scanner/google-charts') }}
+
+<div{{ attributes }}>
+  <div class="achecker-passFailDetails">
+    <div class="achecker-summaryDetails">
+      {% trans %}
+      <h2>Pass/Fail Trends</h2>
+      <p>This chart shows pass/fail trends over time.</p>
+      <ul>
+        <li><strong>Total:</strong> Total number of URLs scanned.</li>
+        <li><strong>Passing:</strong> URLs that pass web accessibility scan.</li>
+        <li><strong>Failing:</strong> URLs that fail web accessibility scan.</li>
+      </ul>
+      {% endtrans %}
+    </div>
+    <div class="achecker-summaryChart" id="achecker_pass_fail_chart"></div>
+    <div class="achecker-clear"></div>
+  </div>
+  <div class="achecker-problemDetails">
+    <div class="achecker-summaryDetails">
+      {% trans %}
+      <h2>Problem Trends</h2>
+      <p>This chart shows cumulative number of errors and problems over time.</p>
+      <ul>
+        <li><strong>Errors:</strong> Cumulative total number of accessibility errors.</li>
+        <li><strong>Likely Problems:</strong> Cumulative total number of likely accessibility issues.</li>
+        <li><strong>Potential Problems:</strong> Cumulative total number of potential accessibility issues.</li>
+      </ul>
+      {% endtrans %}
+    </div>
+    <div class="achecker-summaryChart" id="achecker_problem_chart"></div>
+    <div class="achecker-clear"></div>
+  </div>
+</div>
diff --git a/templates/wpa-achecker-preview.html.twig b/templates/wpa-achecker-preview.html.twig
index 58e6959..c968ff9 100644
--- a/templates/wpa-achecker-preview.html.twig
+++ b/templates/wpa-achecker-preview.html.twig
@@ -1,7 +1,7 @@
 {#
 /**
  * @file
- * Template file the web page archive before/after screenshot comparison.
+ * Template file for the preview report.
  *
  * Available variables:
  * - summary: Object containing summary results.
@@ -14,13 +14,6 @@
 <div class="achecker-result achecker-result-{{ summary.status|lower }}">
   <div class="achecker-result-status">{{ summary.status }}</div>
   <div class="achecker-result-url">{{ url }}</div>
-  {% if summary.guidelines is not empty %}
-  <ul class="achecker-result-guidelines">
-    {% for guideline in summary.guidelines %}
-    <li>{{ guideline }}</li>
-    {% endfor %}
-  </ul>
-  {% endif %}
 
   <ul class="achecker-result-errors">
     {% if summary.num_of_errors > 0 %}
diff --git a/templates/wpa-achecker-summary.html.twig b/templates/wpa-achecker-summary.html.twig
new file mode 100644
index 0000000..6c863da
--- /dev/null
+++ b/templates/wpa-achecker-summary.html.twig
@@ -0,0 +1,45 @@
+{#
+/**
+ * @file
+ * Template file the web page archive before/after screenshot comparison.
+ *
+ * Available variables:
+ * - attributes: Object containing attributes.
+ * - summary: Object containing summary results.
+ * - results: Object containing detailed results.
+ */
+#}
+
+<div{{ attributes.addClass('achecker-runSummary') }}>
+
+  <div class="achecker-runSummary">
+    <h4>{{ 'Cumulative Results:'|trans }}</h4>
+    <div class="achecker-runSummaryColumn">
+      <ul class="achecker-runSummaryList">
+        <li><strong>{{ 'Total:'|trans }}</strong> {{ result.total }}</li>
+        <li><strong>{{ 'Pass:'|trans }}</strong> {{ result.pass }}</li>
+        <li><strong>{{ 'Fail:'|trans }}</strong> {{ result.fail }}</li>
+      </ul>
+    </div>
+    <div class="achecker-runSummaryColumn">
+      <ul class="achecker-runSummaryList">
+        <li><strong>{{ 'Errors:'|trans }}</strong> {{ result.num_of_errors }}</li>
+        <li><strong>{{ 'Likely Problems:'|trans }}</strong> {{ result.num_of_likely_problems }}</li>
+        <li><strong>{{ 'Potential Problems:'|trans }}</strong> {{ result.num_of_potential_problems }}</li>
+      </ul>
+    </div>
+    <div class="achecker-runSummaryColumn">
+      <strong>{{ 'Guidelines:'|trans }}</strong>
+      <ul class="achecker-runSummaryList">
+        {% for guideline in result.guidelines %}
+          <li>{{ guideline }}</li>
+        {% endfor %}
+      </ul>
+    </div>
+    <div class="achecker-runSummaryColumn">
+      {{ trend_button }}
+    </div>
+    <div class="achecker-clear"></div>
+  </div>
+
+</div>
diff --git a/tests/src/Functional/AcheckerEndToEndTest.php b/tests/src/Functional/AcheckerEndToEndTest.php
index 7a7addb..fb63d82 100644
--- a/tests/src/Functional/AcheckerEndToEndTest.php
+++ b/tests/src/Functional/AcheckerEndToEndTest.php
@@ -118,13 +118,33 @@ class AcheckerEndToEndTest extends BrowserTestBase {
 
     // Go view full run.
     $this->clickLink('View Details');
-    $assert->pageTextContains(t('Pass'));
-    $assert->pageTextContains(t('Fail'));
+
+    // Look for summary information.
+    $assert->pageTextContains(t('Achecker Job'));
+    $assert->pageTextContains(t('Total: 4'));
+    $assert->pageTextContains(t('Pass: 2'));
+    $assert->pageTextContains(t('Fail: 2'));
+    $assert->pageTextContains(t('Errors: 4'));
+    $assert->pageTextContains(t('Likely Problems: 2'));
+    $assert->pageTextContains(t('Potential Problems: 8'));
+    $assert->pageTextContains(t('BITV 1.0 (Level 2)'));
     $assert->pageTextContains(t('Section 508'));
-    $assert->pageTextContains(t('WCAG 2.0 (Level AA)'));
+    $assert->pageTextContains(t('View Historical Trends'));
+
+    // Look for individual results.
+    $assert->pageTextContains(t('Fail'));
     $assert->pageTextContains(t('Errors: 2'));
     $assert->pageTextContains(t('Likely Problems: 1'));
     $assert->pageTextContains(t('Potential Problems: 4'));
+
+    // Go to chart page.
+    $this->clickLink('View Historical Trends');
+
+    // Look for drupal settings data.
+    $assert->responseContains('"acheckerResults":{"1":{"entity_id":"1","vid":"1","total":"4","pass":"2","fail":"2","num_of_errors":"4","num_of_likely_problems":"2","num_of_potential_problems":"8","guidelines":"a:2:{s:18:\u0022BITV 1.0 (Level 2)\u0022;s:18:\u0022BITV 1.0 (Level 2)\u0022;s:11:\u0022Section 508\u0022;s:11:\u0022Section 508\u0022;}"');
+    $assert->responseContains('<div class="achecker-summaryChart" id="achecker_problem_chart"></div>');
+    $assert->responseContains('<div class="achecker-summaryChart" id="achecker_pass_fail_chart"></div>');
+
   }
 
 }
-- 
2.15.1 (Apple Git-101)

