Browse Source

Add detailed stats page to see package installs

Jordi Boggiano 9 years ago
parent
commit
4bdb160ab4

+ 208 - 33
src/Packagist/WebBundle/Controller/PackageController.php

@@ -4,8 +4,14 @@ namespace Packagist\WebBundle\Controller;
 
 use Packagist\WebBundle\Form\Type\AbandonedType;
 use Packagist\WebBundle\Entity\Package;
+use Packagist\WebBundle\Entity\Version;
 use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use Symfony\Component\Security\Core\Exception\AccessDeniedException;
+use Composer\Package\Version\VersionParser;
+use DateTimeImmutable;
 
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
@@ -20,17 +26,8 @@ class PackageController extends Controller
      *     requirements={"name"="[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?"}
      * )
      */
-    public function editAction(Request $req, $name)
+    public function editAction(Request $req, Package $package)
     {
-        /** @var $packageRepo \Packagist\WebBundle\Entity\PackageRepository */
-        $packageRepo = $this->getDoctrine()->getRepository('PackagistWebBundle:Package');
-        /** @var $package Package */
-        $package = $packageRepo->findOneByName($name);
-
-        if (!$package) {
-            throw $this->createNotFoundException("The requested package, $name, could not be found.");
-        }
-
         if (!$package->getMaintainers()->contains($this->getUser()) && !$this->get('security.authorization_checker')->isGranted('ROLE_EDIT_PACKAGES')) {
             throw new AccessDeniedException;
         }
@@ -71,18 +68,8 @@ class PackageController extends Controller
      * )
      * @Template()
      */
-    public function abandonAction(Request $request, $name)
+    public function abandonAction(Request $request, Package $package)
     {
-        /** @var $packageRepo \Packagist\WebBundle\Entity\PackageRepository */
-        $packageRepo = $this->getDoctrine()->getRepository('PackagistWebBundle:Package');
-
-        /** @var $package Package */
-        $package = $packageRepo->findOneByName($name);
-
-        if (!$package) {
-            throw $this->createNotFoundException("The requested package, $name, could not be found.");
-        }
-
         if (!$package->getMaintainers()->contains($this->getUser()) && !$this->get('security.authorization_checker')->isGranted('ROLE_EDIT_PACKAGES')) {
             throw new AccessDeniedException;
         }
@@ -115,18 +102,8 @@ class PackageController extends Controller
      *      requirements={"name"="[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?"}
      * )
      */
-    public function unabandonAction($name)
+    public function unabandonAction(Package $package)
     {
-        /** @var $packageRepo \Packagist\WebBundle\Entity\PackageRepository */
-        $packageRepo = $this->getDoctrine()->getRepository('PackagistWebBundle:Package');
-
-        /** @var $package Package */
-        $package = $packageRepo->findOneByName($name);
-
-        if (!$package) {
-            throw $this->createNotFoundException("The requested package, $name, could not be found.");
-        }
-
         if (!$package->getMaintainers()->contains($this->getUser()) && !$this->get('security.authorization_checker')->isGranted('ROLE_EDIT_PACKAGES')) {
             throw new AccessDeniedException;
         }
@@ -140,5 +117,203 @@ class PackageController extends Controller
 
         return $this->redirect($this->generateUrl('view_package', array('name' => $package->getName())));
     }
-}
 
+    /**
+     * @Route(
+     *      "/packages/{name}/stats.{_format}",
+     *      name="view_package_stats",
+     *      requirements={"name"="[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?", "_format"="(json)"},
+     *      defaults={"_format"="html"}
+     * )
+     * @Template()
+     */
+    public function statsAction(Request $req, Package $package)
+    {
+        $versions = $package->getVersions()->toArray();
+        usort($versions, Package::class.'::sortVersions');
+        $date = $this->guessStatsStartDate($package);
+        $data = [
+            'downloads' => $this->get('packagist.download_manager')->getDownloads($package),
+            'versions' => $versions,
+            'grouping' => $this->guessStatsGrouping($date),
+            'date' => $date->format('Y-m-d'),
+        ];
+
+        if ($req->getRequestFormat() === 'json') {
+            $data['versions'] = array_map(function ($version) {
+                return $version->getVersion();
+            }, $data['versions']);
+
+            return new JsonResponse($data);
+        }
+
+        $data['package'] = $package;
+
+        $expandedVersion = reset($versions);
+        foreach ($versions as $v) {
+            if (!$v->isDevelopment()) {
+                $expandedVersion = $v;
+                break;
+            }
+        }
+        $data['expandedId'] = $expandedVersion->getId();
+
+        return $data;
+    }
+
+    /**
+     * @Route(
+     *      "/packages/{name}/stats/all.json",
+     *      name="package_stats",
+     *      requirements={"name"="[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?"}
+     * )
+     */
+    public function overallStatsAction(Request $req, Package $package, Version $version = null)
+    {
+        if ($from = $req->query->get('from')) {
+            $from = new DateTimeImmutable($from);
+        } else {
+            $from = $this->guessStatsStartDate($version ?: $package);
+        }
+        if ($to = $req->query->get('to')) {
+            $to = new DateTimeImmutable($to);
+        } else {
+            $to = new DateTimeImmutable('-2days 00:00:00');
+        }
+        $grouping = $req->query->get('grouping', $this->guessStatsGrouping($from, $to));
+
+        $datePoints = $this->createDatePoints($from, $to, $grouping, $package, $version);
+
+        $redis = $this->get('snc_redis.default');
+        if ($grouping === 'Daily') {
+            $datePoints = array_map(function ($vals) {
+                return $vals[0];
+            }, $datePoints);
+
+            $datePoints = array(
+                'labels' => array_keys($datePoints),
+                'values' => $redis->mget(array_values($datePoints))
+            );
+        } else {
+            $datePoints = array(
+                'labels' => array_keys($datePoints),
+                'values' => array_values(array_map(function ($vals) use ($redis) {
+                    return array_sum($redis->mget(array_values($vals)));
+                }, $datePoints))
+            );
+        }
+
+        $datePoints['grouping'] = $grouping;
+
+        if (empty($datePoints['labels']) && empty($datePoints['values'])) {
+            $datePoints['labels'][] = date('Y-m-d');
+            $datePoints['values'][] = 0;
+        }
+
+        $response = new JsonResponse($datePoints);
+        $response->setSharedMaxAge(1800);
+
+        return $response;
+    }
+
+    /**
+     * @Route(
+     *      "/packages/{name}/stats/{version}.json",
+     *      name="version_stats",
+     *      requirements={"name"="[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?", "version"=".+?"}
+     * )
+     */
+    public function versionStatsAction(Request $req, Package $package, $version)
+    {
+        $normalizer = new VersionParser;
+        $normVersion = $normalizer->normalize($version);
+
+        $version = $this->getDoctrine()->getRepository('PackagistWebBundle:Version')->findOneBy([
+            'package' => $package,
+            'normalizedVersion' => $normVersion
+        ]);
+
+        if (!$version) {
+            throw new NotFoundHttpException();
+        }
+
+        return $this->overallStatsAction($req, $package, $version);
+    }
+
+    private function createDatePoints(DateTimeImmutable $from, DateTimeImmutable $to, $grouping, Package $package, Version $version = null)
+    {
+        $interval = $this->getStatsInterval($grouping);
+
+        $dateKey = $grouping === 'monthly' ? 'Ym' : 'Ymd';
+        $dateFormat = $grouping === 'monthly' ? 'Y-m' : 'Y-m-d';
+        $dateJump = $grouping === 'monthly' ? '+1month' : '+1day';
+        if ($grouping === 'monthly') {
+            $to = new DateTimeImmutable('last day of '.$to->format('Y-m'));
+        }
+
+        $nextDataPointLabel = $from->format($dateFormat);
+        $nextDataPoint = $from->modify($interval);
+
+        $datePoints = [];
+        while ($from <= $to) {
+            $datePoints[$nextDataPointLabel][] = 'dl:'.$package->getId().($version ? '-' . $version->getId() : '').':'.$from->format($dateKey);
+
+            $from = $from->modify($dateJump);
+            if ($from >= $nextDataPoint) {
+                $nextDataPointLabel = $from->format($dateFormat);
+                $nextDataPoint = $from->modify($interval);
+            }
+        }
+
+        return $datePoints;
+    }
+
+    private function guessStatsStartDate($packageOrVersion)
+    {
+        if ($packageOrVersion instanceof Package) {
+            $date = DateTimeImmutable::createFromMutable($packageOrVersion->getCreatedAt());
+        } elseif ($packageOrVersion instanceof Version) {
+            $date = DateTimeImmutable::createFromMutable($packageOrVersion->getReleasedAt());
+        } else {
+            throw new \LogicException('Version or Package expected');
+        }
+
+        $statsRecordDate = new DateTimeImmutable('2012-04-13 00:00:00');
+        if ($date < $statsRecordDate) {
+            $date = $statsRecordDate;
+        }
+
+        return $date->setTime(0, 0, 0);
+    }
+
+    private function guessStatsGrouping(DateTimeImmutable $from, DateTimeImmutable $to = null)
+    {
+        if ($to === null) {
+            $to = new DateTimeImmutable('-2 days');
+        }
+        if ($from < $to->modify('-48months')) {
+            $grouping = 'monthly';
+        } elseif ($from < $to->modify('-7months')) {
+            $grouping = 'weekly';
+        } else {
+            $grouping = 'daily';
+        }
+
+        return $grouping;
+    }
+
+    private function getStatsInterval($grouping)
+    {
+        $intervals = [
+            'monthly' => '+1month',
+            'weekly' => '+7days',
+            'daily' => '+1day',
+        ];
+
+        if (!isset($intervals[$grouping])) {
+            throw new BadRequestHttpException();
+        }
+
+        return $intervals[$grouping];
+    }
+}

+ 1 - 14
src/Packagist/WebBundle/Controller/WebController.php

@@ -657,20 +657,7 @@ class WebController extends Controller
             $versions = $versions->toArray();
         }
 
-        usort($versions, function ($a, $b) {
-            $aVersion = $a->getNormalizedVersion();
-            $bVersion = $b->getNormalizedVersion();
-            $aVersion = preg_replace('{^dev-.*}', '0.0.0-alpha', $aVersion);
-            $bVersion = preg_replace('{^dev-.*}', '0.0.0-alpha', $bVersion);
-
-            // equal versions are sorted by date
-            if ($aVersion === $bVersion) {
-                return $b->getReleasedAt() > $a->getReleasedAt() ? 1 : -1;
-            }
-
-            // the rest is sorted by version
-            return version_compare($bVersion, $aVersion);
-        });
+        usort($versions, Package::class.'::sortVersions');
 
         if (count($versions)) {
             $versionRepo = $this->getDoctrine()->getRepository('PackagistWebBundle:Version');

+ 16 - 0
src/Packagist/WebBundle/Entity/Package.php

@@ -778,4 +778,20 @@ class Package
     {
         $this->replacementPackage = $replacementPackage;
     }
+
+    public static function sortVersions($a, $b)
+    {
+        $aVersion = $a->getNormalizedVersion();
+        $bVersion = $b->getNormalizedVersion();
+        $aVersion = preg_replace('{^dev-.*}', '0.0.0-alpha', $aVersion);
+        $bVersion = preg_replace('{^dev-.*}', '0.0.0-alpha', $bVersion);
+
+        // equal versions are sorted by date
+        if ($aVersion === $bVersion) {
+            return $b->getReleasedAt() > $a->getReleasedAt() ? 1 : -1;
+        }
+
+        // the rest is sorted by version
+        return version_compare($bVersion, $aVersion);
+    }
 }

+ 2 - 2
src/Packagist/WebBundle/Model/DownloadManager.php

@@ -115,12 +115,12 @@ class DownloadManager
     public function addDownload($package, $version)
     {
         static $loaded;
+        $redis = $this->redis;
+
         if (!$loaded) {
             $redis->getProfile()->defineCommand('downloadsIncr', 'Packagist\Redis\DownloadsIncr');
         }
 
-        $redis = $this->redis;
-
         if ($package instanceof Package) {
             $package = $package->getId();
         }

+ 17 - 0
src/Packagist/WebBundle/Resources/public/css/main.css

@@ -1433,6 +1433,23 @@ ul.packages .metadata-block:last-child {
     margin-top: 40px;
 }
 
+.package-installs dt {
+  float: left;
+  min-width: 125px;
+  text-align: right;
+  margin-right: 20px;
+}
+.package-installs dl {
+  margin-bottom: 5px;
+}
+.version-stats.package .package-aside.versions-wrapper {
+  margin-top: 0;
+}
+.version-stats-chart {
+  width: 70%;
+  margin-right: 5%;
+  position: relative;
+}
 
 
 [class^="icon-"]:before,

+ 94 - 12
src/Packagist/WebBundle/Resources/public/js/charts.js

@@ -10,16 +10,15 @@
     Chart.defaults.global.animationSteps = 10;
     Chart.defaults.global.tooltipYPadding = 10;
 
-    $('canvas[data-labels]').each(function () {
-        var element = $(this);
-        var labels = element.attr('data-labels').split(',');
-        var values = element.attr('data-values').split('|');
-        var ctx = this.getContext("2d");
+    function initPackagistChart(canvas, labels, values, scale, tooltips) {
+        var ctx = canvas.getContext("2d");
         var data = {
-            labels: labels,
+            labels: labels.map(function (val, index, arr) {
+                return index % Math.round(arr.length / 50) == 0 ? val : '';
+            }),
             datasets: []
         };
-        var scale = parseInt(element.attr('data-scale'), 10);
+        var scale = parseInt(scale, 10);
         var scaleUnit = '';
         switch (scale) {
             case 1000:
@@ -32,9 +31,15 @@
 
         var opts = {
             bezierCurve: false,
-            scaleLabel: " <%=value%>" + scaleUnit
+            scaleLabel: " <%=value%>" + scaleUnit,
+            tooltipTemplate: "<%if (label){%><%=label%>: <%}%><%= value %>" + scaleUnit,
+            pointDot: false
         };
 
+        if (!tooltips || labels.length > 50 || labels.length <= 2) {
+            opts.showTooltips = false;
+        }
+
         for (var i = 0; i < values.length; i++) {
             data.datasets.push(
                 {
@@ -42,14 +47,91 @@
                     strokeColor: colors[i],
                     pointColor: colors[i],
                     pointStrokeColor: "#fff",
-                    data: values[i].split(',')
-                        .map(function (value) {
-                            return Math.round(parseInt(value, 10) / scale, 2);
-                        })
+                    data: values[i].map(function (value) {
+                        return Math.round(parseInt(value, 10) / scale, 2);
+                    })
                 }
             );
         }
 
         new Chart(ctx).Line(data, opts);
+    };
+
+    $('canvas[data-labels]').each(function () {
+        initPackagistChart(
+            this,
+            $(this).attr('data-labels').split(','),
+            $(this).attr('data-values').split('|').map(function (values) { return values.split(','); }),
+            $(this).attr('data-scale'),
+            true
+        );
     });
+
+    window.initPackageStats = function (grouping, date, versions, statsUrl, versionStatsUrl) {
+        var match,
+            hash = document.location.hash,
+            versionCache = {},
+            ongoingRequest = false;
+
+        $.ajax({
+            url: statsUrl,
+            success: function (res) {
+                initPackagistChart($('.js-all-dls')[0], res.labels, [res.values], Math.max.apply(res.values) > 10000 ? 1000 : 1, false);
+            }
+        })
+        function loadVersionChart(versionId) {
+            ongoingRequest = true;
+            $.ajax({
+                url: versionStatsUrl.replace('_VERSION_', versionId) + '?from=' + date,
+                success: function (res) {
+                    initPackagistChart($('.js-version-dls')[0], res.labels, [res.values], Math.max.apply(res.values) > 10000 ? 1000 : 1, false);
+                    versionCache[versionId] = res;
+                    ongoingRequest = false;
+                }
+            });
+        }
+
+        // initializer for #<version-id> present on page load
+        if (hash.length > 1) {
+            hash = hash.substring(1);
+            match = $('.package .details-toggler[data-version-id="'+hash+'"]');
+            if (match.length) {
+                $('.package .details-toggler.open').removeClass('open');
+                match.addClass('open');
+            }
+        }
+
+        loadVersionChart($('.package .details-toggler.open').attr('data-version-id'));
+
+        $('.package .details-toggler').on('click', function () {
+            var res, target = $(this), versionId = target.attr('data-version-id');
+
+            if (versionCache[versionId]) {
+                res = versionCache[versionId];
+                initPackagistChart($('.js-version-dls')[0], res.labels, [res.values], Math.max.apply(res.values) > 10000 ? 1000 : 1, false);
+            } else {
+                if (ongoingRequest) {
+                    return;
+                }
+                loadVersionChart(versionId);
+            }
+
+            $('.package .details-toggler.open').removeClass('open');
+            target.addClass('open');
+        });
+
+        $(window).on('scroll', function () {
+            $('.version-stats-chart').css('top', Math.max(0, window.scrollY - $('.version-stats').offset().top + 80) + 'px');
+        });
+
+        // initialize version list expander
+        var versionsList = $('.package .versions')[0];
+        if (versionsList.offsetHeight < versionsList.scrollHeight) {
+            $('.package .versions-expander').removeClass('hidden').on('click', function () {
+                $(this).addClass('hidden');
+                $(versionsList).css('overflow-y', 'visible')
+                    .css('max-height', 'inherit');
+            });
+        }
+    };
 })(jQuery);

+ 11 - 11
src/Packagist/WebBundle/Resources/public/js/view.js

@@ -5,18 +5,18 @@
     var versionCache = {},
         ongoingRequest = false;
 
-    $('#add-maintainer').click(function (e) {
+    $('#add-maintainer').on('click', function (e) {
         $('#remove-maintainer-form').addClass('hidden');
-        $('#add-maintainer-form').toggleClass('hidden');
+        $('#add-maintainer-form').removeClass('hidden');
         e.preventDefault();
     });
-    $('#remove-maintainer').click(function (e) {
+    $('#remove-maintainer').on('click', function (e) {
         $('#add-maintainer-form').addClass('hidden');
-        $('#remove-maintainer-form').toggleClass('hidden');
+        $('#remove-maintainer-form').removeClass('hidden');
         e.preventDefault();
     });
 
-    $('.package .details-toggler').click(function () {
+    $('.package .details-toggler').on('click', function () {
         var target = $(this);
 
         if (versionCache[target.attr('data-version-id')]) {
@@ -41,7 +41,7 @@
         }
 
         $('.package .versions .open').removeClass('open');
-        target.toggleClass('open');
+        target.addClass('open');
     });
 
     // initializer for #<version-id> present on page load
@@ -93,8 +93,8 @@
         }).complete(function () { submit.removeClass('loading'); });
         submit.addClass('loading');
     }
-    $('.package .force-update').submit(forceUpdatePackage);
-    $('.package .mark-favorite').click(function (e) {
+    $('.package .force-update').on('submit', forceUpdatePackage);
+    $('.package .mark-favorite').on('click', function (e) {
         var options = {
             dataType: 'json',
             cache: false,
@@ -118,7 +118,7 @@
         $.ajax(options).complete(function () { $(this).removeClass('loading'); });
         $(this).addClass('loading');
     });
-    $('.package .delete').submit(function (e) {
+    $('.package .delete').on('submit', function (e) {
         e.preventDefault();
         if (window.confirm('Are you sure?')) {
             dispatchAjaxForm(this, function () {
@@ -129,13 +129,13 @@
             }, 'request-sent');
         }
     });
-    $('.package .delete-version .submit').click(function (e) {
+    $('.package .delete-version .submit').on('click', function (e) {
         e.preventDefault();
         e.stopImmediatePropagation();
         $(e.target).closest('form').submit();
     });
 
-    $('.package .delete-version').submit(function (e) {
+    $('.package .delete-version').on('submit', function (e) {
         e.preventDefault();
         e.stopImmediatePropagation();
         var form = this;

+ 77 - 0
src/Packagist/WebBundle/Resources/views/Package/stats.html.twig

@@ -0,0 +1,77 @@
+{% extends "PackagistWebBundle::layout.html.twig" %}
+
+{% set showSearchDesc = 'hide' %}
+
+{% block title %}Install Statistics - {{ package.name }} - {{ parent() }}{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-xs-12 package"{% if app.user and package.crawledAt is null and (is_granted('ROLE_EDIT_PACKAGES') or package.maintainers.contains(app.user)) %} data-force-crawl="true"{% endif %}>
+            <div class="package-header">
+                <h2 class="title">
+                    <a href="{{ path("view_vendor", {"vendor": package.vendor}) }}">{{ package.vendor }}/</a><a href="{{ path("view_package", {name: package.name}) }}">{{ package.packageName }}</a> install statistics
+                </h2>
+            </div>
+        </div>
+    </div>
+
+    <section class="row package-installs">
+        <div class="col-lg-12">
+            <h3>Installs</h3>
+
+            <div class="row">
+                <div class="col-md-4 col-xs-12">
+                    <dl class="dl-horizontal">
+                        <dt class="font-normal">Overall</dt>
+                        <dd class="font-normal">{{ downloads.total|number_format(0, '.', '&#8201;')|raw }}</dd>
+                    </dl>
+                </div>
+                <div class="col-md-4 col-xs-12">
+                    <dl class="dl-horizontal">
+                        <dt class="font-normal">Last 30 days</dt>
+                        <dd class="font-normal">{{ downloads.monthly|number_format(0, '.', '&#8201;')|raw }}</dd>
+                    </dl>
+                </div>
+                <div class="col-md-4 col-xs-12">
+                    <dl class="dl-horizontal">
+                        <dt class="font-normal">Today<br><small>(since midnight, UTC)</small></dt>
+                        <dd class="font-normal">{{ downloads.daily|number_format(0, '.', '&#8201;')|raw }}</dd>
+                    </dl>
+                </div>
+            </div>
+
+            <h3>Installs, all versions, {{ grouping }}</h3>
+            <div class="row">
+                <div class="col-xs-12">
+                    <canvas class="js-all-dls" width="500" height="200">
+                        Sorry, the graph can't be displayed because your browser doesn't support &lt;canvas&gt; html element.
+                    </canvas>
+                </div>
+            </div>
+
+            <h3>Installs, per versions, {{ grouping }}</h3>
+            <div class="row package version-stats">
+                <div class="col-xs-12 col-md-9 version-stats-chart">
+                    <canvas class="js-version-dls" width="500" height="200">
+                        Sorry, the graph can't be displayed because your browser doesn't support &lt;canvas&gt; html element.
+                    </canvas>
+                </div>
+                {% include 'PackagistWebBundle:Web:versionList.html.twig' with {versions: versions, expandedId: expandedId} %}
+            </div>
+        </div>
+    </section>
+{% endblock %}
+
+{% block scripts %}
+    <script src="{{ asset('js/libs/Chart.js/Chart.min.js') }}"></script>
+    <script src="{{ asset('bundles/packagistweb/js/charts.js') }}"></script>
+    <script>
+        (function () {
+            var grouping = {{ grouping|json_encode|raw }};
+            var date = {{ date|json_encode|raw }};
+            var versions = {{ versions|json_encode|raw }};
+
+            initPackageStats(grouping, date, versions, {{ path('package_stats', {name: package.name})|json_encode|raw }}, {{ path('version_stats', {name: package.name, version: '_VERSION_'})|json_encode|raw }});
+        }());
+    </script>
+{% endblock %}

+ 25 - 0
src/Packagist/WebBundle/Resources/views/Web/versionList.html.twig

@@ -0,0 +1,25 @@
+<div class="col-md-3 package-aside versions-wrapper">
+    <ul class="versions">
+        {% for version in versions %}
+            {% set expanded = version.id == expandedId|default(false) %}
+            <li class="details-toggler version{% if loop.last %} last{% endif %}{% if expanded %} open{% endif %}" data-version-id="{{ version.version }}" data-load-more="{{ path('view_version', {versionId: version.id, _format: 'json'}) }}">
+                <a href="#{{ version.version }}" class="version-number">
+                    {{- version.version -}}
+                    {% if version.hasVersionAlias() %}
+                        / {{ version.versionAlias }}
+                    {% endif -%}
+                </a>
+
+                {% if deleteVersionCsrfToken is defined %}
+                <form class="delete-version" action="{{ path("delete_version", {"versionId": version.id}) }}" method="DELETE">
+                    <input type="hidden" name="_token" value="{{ deleteVersionCsrfToken }}" />
+                    <i class="submit glyphicon glyphicon-remove"></i>
+                </form>
+                {% endif %}
+            </li>
+        {% endfor %}
+    </ul>
+    <div class="hidden versions-expander">
+        <i class="glyphicon glyphicon-chevron-down"></i>
+    </div>
+</div>

+ 3 - 30
src/Packagist/WebBundle/Resources/views/Web/viewPackage.html.twig

@@ -142,14 +142,11 @@
                             {% if version and version.support.docs is defined %}
                                 <p><a href="{{ version.support.docs }}">Documentation</a></p>
                             {% endif %}
+                            <p><a href="{{ path('view_package_stats', {name: package.name}) }}">Statistics</a></p>
                         </div>
 
                         <div class="facts col-xs-12 col-sm-6 col-md-12">
-                            <p><strong>Installs</strong></p>
-                            <p><span>Overall:</span> {% if downloads.total is defined %}{{ downloads.total|number_format(0, '.', '&#8201;')|raw }}{% else %}N/A{% endif %}</p>
-                            <p><span>30 days:</span> {% if downloads.monthly is defined %}{{ downloads.monthly|number_format(0, '.', '&#8201;')|raw }}{% else %}N/A{% endif %}</p>
-                            <p><span>Today:</span> {% if downloads.daily is defined %}{{ downloads.daily|number_format(0, '.', '&#8201;')|raw }}{% else %}N/A{% endif %}</p>
-                            {% if package.gitHubWatches is not null %}<br>{% endif %}
+                            <p><span>Installs:</span> {% if downloads.total is defined %}{{ downloads.total|number_format(0, '.', '&#8201;')|raw }}{% else %}N/A{% endif %}</p>
                             {% if package.language is not empty %}<p><span>Language:</span> {{ package.language }}</p>{% endif %}
                             {% if package.gitHubStars is not null %}<p><span>Stars:</span> {{ package.gitHubStars|number_format(0, '.', '&#8201;')|raw }}</p>{% endif %}
                             {% if package.gitHubWatches is not null %}<p><span>Watches:</span> {{ package.gitHubWatches|number_format(0, '.', '&#8201;')|raw }}</p>{% endif %}
@@ -201,31 +198,7 @@
                         {% include 'PackagistWebBundle:Web:versionDetails.html.twig' with {version: expandedVersion} %}
                     {% endif %}
                 </div>
-                <div class="col-md-3 package-aside versions-wrapper">
-                    <ul class="versions">
-                        {% for version in versions %}
-                            {% set expanded = version.id == expandedVersion.id|default(false) %}
-                            <li class="details-toggler version{% if loop.last %} last{% endif %}{% if expanded %} open{% endif %}" data-version-id="{{ version.version }}" data-load-more="{{ path('view_version', {versionId: version.id, _format: 'json'}) }}">
-                                <a href="#{{ version.version }}" class="version-number">
-                                    {{- version.version -}}
-                                    {% if version.hasVersionAlias() %}
-                                        / {{ version.versionAlias }}
-                                    {% endif -%}
-                                </a>
-
-                                {% if deleteVersionCsrfToken is defined %}
-                                <form class="delete-version" action="{{ path("delete_version", {"versionId": version.id}) }}" method="DELETE">
-                                    <input type="hidden" name="_token" value="{{ deleteVersionCsrfToken }}" />
-                                    <i class="submit glyphicon glyphicon-remove"></i>
-                                </form>
-                                {% endif %}
-                            </li>
-                        {% endfor %}
-                    </ul>
-                    <div class="hidden versions-expander">
-                        <i class="glyphicon glyphicon-chevron-down"></i>
-                    </div>
-                </div>
+                {% include 'PackagistWebBundle:Web:versionList.html.twig' with {versions: versions, expandedId: expandedVersion.id, deleteVersionCsrfToken: deleteVersionCsrfToken} %}
             {% elseif package.crawledAt is null %}
                 <p class="col-xs-12">This package has not been crawled yet, some information is missing.</p>
             {% else %}