]> _ Git - fluidbook-toolbox.git/commitdiff
Wait #5316 @30
authorStephen Cameron <stephen@cubedesigners.com>
Wed, 21 Sep 2022 16:15:04 +0000 (18:15 +0200)
committerStephen Cameron <stephen@cubedesigners.com>
Wed, 21 Sep 2022 16:15:04 +0000 (18:15 +0200)
app/Http/Controllers/Admin/Operations/FluidbookPublication/StatsOperation.php
composer.json
public/packages/daterangepicker/daterangepicker.css [new file with mode: 0755]
public/packages/daterangepicker/daterangepicker.js [new file with mode: 0755]
public/packages/sorttable/sorttable.js [new file with mode: 0644]
resources/views/fluidbook_stats/API.blade.php
resources/views/fluidbook_stats/summary.blade.php

index 7c68a0aaf534c9070c26b5965332777f610b8ada..07ddcfb80ca7f2d0c97885a9561f65fabdebf35b 100644 (file)
@@ -5,18 +5,26 @@ namespace App\Http\Controllers\Admin\Operations\FluidbookPublication;
 use App\Http\Middleware\CheckIfAdmin;
 use App\Models\FluidbookPublication;
 use Carbon\Carbon;
+use Carbon\CarbonInterface;
 use Cubist\Matomo\Reporting;
 use Illuminate\Support\Facades\Route;
+use NumberFormatter;
 
 trait StatsOperation
 {
     protected function setupStatsRoutes($segment, $routeName, $controller)
     {
-        Route::get($segment . '/stats/API', $controller . '@statsAPI');
-        // Route is only secured by hash
+        // Main route is only secured by hash (security by obscurity)
         Route::get($segment . '/stats/{fluidbook_id}_{hash}/{date?}', $controller . '@statsSummary')
             ->withoutMiddleware([CheckIfAdmin::class])
             ->name('stats'); // Named route is used to generate URLs more consistently using route helper
+
+        // Shortcuts for easier access to hashed URLs - only users with sufficient permissions will be redirected
+        Route::get($segment . '/stats/{fluidbook_id}', $controller . '@statsRedirect');
+        Route::get($segment . '/{fluidbook_id}/stats', $controller . '@statsRedirect');
+
+        // API testing tool (intended for superadmins only)
+        Route::get($segment . '/stats/API', $controller . '@statsAPI');
     }
 
     protected function setupStatsDefaults()
@@ -25,7 +33,7 @@ trait StatsOperation
     }
 
 
-    public static function getMatomoTokens()
+    protected function getMatomoTokens()
     {
         // Each stats server has a different instance of Matamo, so we need to provide different API tokens for each
         // Normally this information would be stored in the .env but there's no good way to do that with an array, so
@@ -37,9 +45,8 @@ trait StatsOperation
         ];
     }
 
-    private function _getReporting($fluidbook_id)
-    {
-        // Get the appropriate server / API token based on the Fluidbook ID
+    protected function getMatomoServer($fluidbook_id) {
+        // Get the appropriate server based on the Fluidbook ID
         // Stats are split across different servers depending on the ID:
         // ID < 21210 = stats3.fluidbook.com
         // ID >= 21210 (even numbers) = stats4.fluidbook.com
@@ -49,22 +56,27 @@ trait StatsOperation
 
         if ($fluidbook_id < 21210) {
             $server = 'stats3.fluidbook.com';
-        } elseif ($fluidbook_id >= 21210 && $fluidbook_id % 2 === 0) {
+        } elseif ($fluidbook_id % 2 === 0) {
             $server = 'stats4.fluidbook.com';
         } else {
             $server = 'stats5.fluidbook.com';
         }
 
-        //dump("Server is $server");
-
-        $matomo_tokens = self::getMatomoTokens();
+        return $server;
+    }
 
-        return new Reporting("https://{$server}/", $matomo_tokens[$server]);
+    protected function getMatomoToken($server) : bool|string {
+        $tokens = $this->getMatomoTokens();
+        return $tokens[$server] ?? false;
     }
 
+    private function getReporting($fluidbook_id) : Reporting {
+        $server = $this->getMatomoServer($fluidbook_id);
+        $token = $this->getMatomoToken($server);
+        return new Reporting("https://{$server}/", $token, $fluidbook_id);
+    }
 
-    private function _parseDate($date)
-    {
+    private function parseDate($date) {
         // Match possible date strings:
         // - YYYY
         // - YYYY-MM
@@ -75,23 +87,32 @@ trait StatsOperation
 
         preg_match($regex, $date, $date_matches);
 
+        // Bail out on nonsensical dates
+        if(isset($date_matches['start_date']) && isset($date_matches['end_date']) && ($date_matches['start_date'] > $date_matches['end_date'])) {
+            return false;
+        }
+
         return $date_matches;
     }
 
-    protected function statsSummary($fluidbook_id, $hash, $date = null)
-    {
-        $dates = $date ? $this->_parseDate($date) : false;
+    protected function statsSummary($fluidbook_id, $hash, $date = null) {
 
-        $fluidbook = FluidbookPublication::where('id', $fluidbook_id)->where('hash', $hash)->first();
-        if (null === $fluidbook) {
-            abort(404);
-        }
+        $dates = $date ? $this->parseDate($date) : false;
+        $locale = app()->getLocale();
+        $base_URL = route('stats', compact('fluidbook_id', 'hash')); // Used by date range picker to update report URL
+        $report_timespan = '';
 
-        // TODO: year(s)? view like the old version: https://workshop.fluidbook.com/stats/10003_ab3cacc39ebbf2478e0931629d114e74
-        // Need to calculate all the available dates, probably based on creation date of the Fluidbook
+        $fluidbook = FluidbookPublication::where('id', $fluidbook_id)->where('hash', $hash)->firstOrFail();
+        $fluidbook_settings = json_decode($fluidbook->settings);
 
-        // TODO: month view, breakdown of individual day stats: https://workshop.fluidbook.com/stats/10003_ab3cacc39ebbf2478e0931629d114e74/2017/10
-        // These would be linked from the "Year(s)" view above...
+        // The page count setting is sometimes missing for older Fluidbooks
+        $page_count = $fluidbook_settings->pages ?? count(explode(',', $fluidbook->page_numbers));
+
+        // In this Fluidbook, are pages independent or grouped into double-page spreads?
+        // This determines how page stats will be counted and displayed:
+        // - In double-page Fluidbooks, only views on the even page numbers are counted (except for bookmarks & shares)
+        // - When pages are independent, all pages are counted for stats
+        $pages_are_independent = in_array($fluidbook_settings->mobileNavigationType ?? [], ['mobilefirst', 'portrait']);
 
         // Matomo API
         // We need to pass it a date (eg. "2022-01-01") or date range (eg. "2022-03-01,2022-05-15")
@@ -104,107 +125,389 @@ trait StatsOperation
         // Which mode are we in?
         if (isset($dates['start_date']) && isset($dates['end_date'])) { // Custom date range
             $mode = 'range';
-            $date_range = "{$dates['start_date']},{$dates['end_date']}";
+            $start_date = $dates['start_date'];
+            $end_date = $dates['end_date'];
+            $date_range = "{$start_date},{$end_date}";
             $period = 'day'; // Segregate stats by day
+            $formatted_date_range = Carbon::parse($start_date)->isoFormat('MMMM Do, YYYY') . ' &mdash; ' .
+                                    Carbon::parse($end_date)->isoFormat('MMMM Do, YYYY');
+            $chart_heading = __('Daily Details');
+
+            // Human-friendly representation of the time span
+            // Since our start and end dates are only in the format YYYY-MM-DD, the time defaults to midnight
+            // on those days. Therefore, when getting the difference, it's always a day short because it only
+            // calculates until the *start* of the end_date, effectively excluding it. To get a clean result,
+            // we add 1 day to the end date (setting time to 23:59:59 has undesired effects with the diff display)
+            $report_timespan = Carbon::parse($dates['start_date'])->startOfDay()->diffForHumans(Carbon::parse($dates['end_date'])->addDay(), [
+                'syntax' => CarbonInterface::DIFF_ABSOLUTE,
+                'parts' => 3, // How much detail to go into (ie. years, months, days)
+                'join' => true, // Join string with natural language separators for the locale
+            ]);
+
 
         } elseif (isset($dates['start_year']) && isset($dates['start_month'])) { // Month view
             $mode = 'month';
             $month = $dates['start_month'];
             $year = $dates['start_year'];
             $last_day_of_month = cal_days_in_month(CAL_GREGORIAN, $month, $year);
-            $date_range = "{$year}-{$month}-01,{$year}-{$month}-{$last_day_of_month}";
+            $start_date = "{$year}-{$month}-01";
+            $date_range = "{$start_date},{$year}-{$month}-{$last_day_of_month}";
+            $end_date = ($month == date('m') && $year == date('Y')) ? date('Y-m-d') : "{$year}-{$month}-{$last_day_of_month}";
             $period = 'day'; // Segregate stats by day
+            $chart_heading = __('Daily Details') .
+                             '<span class="heading-subtitle">' . Carbon::parse($start_date)->isoFormat('MMMM YYYY') . '</span>';
+            $formatted_date_range = Carbon::parse($start_date)->isoFormat('Do') . ' &mdash; ' .
+                                    Carbon::parse($end_date)->isoFormat('Do MMMM, YYYY');
 
-        } elseif (isset($dates['start_year'])) {
+        } elseif (isset($dates['start_year'])) { // Year view
             $mode = 'year';
-            $year = $dates['start_year'];
-            $end_date = $year == date('Y') ? date('Y-m-d') : "{$year}-12-31"; // If it's the current year, don't get future dates
-            $date_range = "{$year}-01-01,{$end_date}"; // Full range of specified year
             $period = 'month'; // Segregate stats by month
+            $year = $dates['start_year'];
+            $start_date = "{$year}-01-01";
+            $date_range = "{$start_date},{$year}-12-31"; // Full range of specified year (guarantees a full chart, even if it's early in the current year)
+            $end_date = $year == date('Y') ? date('Y-m-d') : "{$year}-12-31"; // If it's the current year, don't count future dates
+            $formatted_date_range = Carbon::parse($start_date)->isoFormat('MMMM Do, YYYY') . ' &mdash; ' .
+                                    Carbon::parse($end_date)->isoFormat('MMMM Do, YYYY');
+            $chart_heading = __('Annual Details') . "<span class='heading-subtitle'>$year</span>";
 
         } else { // No valid dates specified, display the full data set
             $mode = 'overview';
+            $period = 'month'; // Segregate stats by month
             $start_date = $fluidbook->created_at->isoFormat('YYYY-MM-DD');
-            $date_range = $start_date . ',' . date('Y-m-d');
+            $end_date = Carbon::now()->isoFormat('YYYY-MM-DD');
 
-            // In some cases, the range will be very short (eg. just a few days or a month), which makes the bar chart
-            // look strange due to lack of entries. There's no easy way to limit the width of the bar chart while
+            // If the Fluidbook is recent, the range will be very short (eg. just a few days or a month), which makes the
+            // bar chart look strange due to lack of entries. There's no easy way to limit the width of the bar chart while
             // retaining the responsiveness, so instead we fetch a longer period of stats, even if they'll be mostly empty.
             if (Carbon::now()->diffInMonths($fluidbook->created_at) < 12) { // For the overview, we want at least 12 months
-                $date_range = 'last12'; // Special range format that Matomo understands
+                //$date_range = 'last12'; // Special date that Matomo understands
+                // Originally this used the special "last12" setting that Matomo understands but this causes problems
+                // later when we need to fetch certain statistics with a period of "range" - real dates are needed.
+                // To make sure the populated bar graph data is at the left of the chart instead of the right,
+                // we fetch future dates (Matomo just returns empty values in this case)
+                // TODO: see if there's a more elegant solution to this, possibly in Chart.js
+                $end_date = $fluidbook->created_at->addMonths(12)->isoFormat('YYYY-MM-DD');
             }
 
-            $period = 'month'; // Segregate stats by month
+            $date_range = "{$start_date},{$end_date}"; // All data up until today's date
+            $formatted_date_range = $fluidbook->created_at->isoFormat('MMMM Do, YYYY') . ' &mdash; ' .
+                                    Carbon::now()->isoFormat('MMMM Do, YYYY');
+            $chart_heading = __('Overview');
         }
 
-        // TODO: support the ability to specify a date range from a date-picker and also maybe choose the breakdown (by year/month/day/range)
+        //=== Set up Matomo Reporting API
+        $report = $this->getReporting($fluidbook_id);
+        $report->setDate($date_range);
+        $report->setPeriod($period);
+
+        // * Note about visits and page views:
+        // Although Matomo records visits and page views (API: VisitsSummary.get & Actions.get), we don't use these
+        // stats because they don't give a detailed or useful result. Instead, we use the per-page statistics
+        // because this allows us to filter out certain non-relevant stats (eg. there's an "open Fluidbook" page view
+        // recorded when first loading a Fluidbook but this is effectively a duplicate page view because another view
+        // is recorded for the initial page at the same time). In the future, we might use these extra stats for other
+        // types of reports but for now, they have to be filtered.
+
+        //=== CUSTOM EVENT STATISTICS (zooms, bookmarks, shares, links, downloads, prints, etc.)
+        // This gathers the events based on their categories and segregated by periods (days, months etc).
+        // Events are returned in a different structure and aren't always the same for each period.
+        // They are converted to be keyed by the event category name, allowing easier access
+        // Note: this API request includes the expanded subtable data, which is only needed for the "menu" category
+        // because it contains the "Download" and "Print" events as children and this structure can't be changed.
+        $eventsByPeriod = collect($report->getEventsByCategory(['expanded' => 1]))->map(function($item, $key) {
+            return collect($item)->keyBy('label');
+        });
+
+        // We also need to get the event stats, segregated by page number for the whole date range.
+        // While this could probably be calculated from the $eventsByPeriod data, it's cleaner to use the API
+        $eventsByPage = collect($report->getEventsByCategory(['expanded' => 1, 'period' => 'range']))
+            ->keyBy('label')
+            ->map(function($item, $key) {
+                // Make subtable data easier to lookup by keying them with the labels (ie. page numbers) for certain events
+                if (in_array($item['label'], ['zoom', 'bookmark', 'share']) && isset($item['subtable'])) {
+                    $item['subtable'] = collect($item['subtable'])->keyBy(function($item, $key) {
+                        // Since there's some inconsistency in the way labels are stored (some have "page x" instead
+                        // of just the number), we strip out any non-numeric values when making the keys
+                        return preg_replace('~\D~', '', $item['label']);
+                    });
+                }
+                return $item;
+        });
+
+        //=== PER-PAGE STATISTICS
+        // Fetch stats for pages, separated by their URLs.
+        // Since we're interested in per-page not per-period stats, we fetch the report flattened for the full date range
+        $pageUrls = collect($report->getPageUrls(['period' => 'range', 'flat' => 1]))
+            ->keyBy('label')
+            ->reject(function($value, $key) {
+                // Remove the '/' URL because it's not a real page (it's created by the "Open Fluidbook"
+                // event and a separate, "real" page view is recorded at the same time)
+                return $key === '/';
+            });
+
+        //=== Group and combine page stats with related data
+        $pages = [];
+        for ($page_number = 1; $page_number <= $page_count; $page_number++) {
+            // For first and last pages or independent pages, don't group pages into spreads
+            // There's also a special case where a Fluidbook has spreads but has uneven pages
+            // (ie. no back cover, so the last page shouldn't be excluded from grouping)
+            if ($pages_are_independent || $page_number === 1 || ($page_number === $page_count && $page_count % 2 === 0)) {
+                $page_group = $page_number;
+
+                $pages[$page_group] = [
+                    'page_group'   => $page_group,
+                    'page_number'  => $page_number, // Used by table column sorter
+                    'nb_visits'    => $pageUrls["/page/$page_number"]['nb_visits'] ?? 0,
+                    'nb_pageviews' => $pageUrls["/page/$page_number"]['nb_hits'] ?? 0,
+                    'nb_zooms'     => data_get($eventsByPage, "zoom.subtable.$page_number.nb_events", 0),
+                    'nb_bookmarks' => data_get($eventsByPage, "bookmark.subtable.$page_number.nb_events", 0),
+                    'nb_shares'    => data_get($eventsByPage, "share.subtable.$page_number.nb_events", 0),
+                ];
+
+                // Special case: the first page of double-page Fluidbooks is tracked as "page 0", meaning that
+                // for our stats labelled as "page 1", we must get certain stats from the "page 0" data...
+                if (!$pages_are_independent && $page_number === 1) {
+                    $pages[$page_group]['nb_visits'] = $pageUrls["/page/0"]['nb_visits'] ?? 0;
+                    $pages[$page_group]['nb_pageviews'] = $pageUrls["/page/0"]['nb_hits'] ?? 0;
+                    $pages[$page_group]['nb_zooms'] = data_get($eventsByPage, "zoom.subtable.0.nb_events", 0);
+
+                    // Bookmarks and shares are already set above, using the page 1 data.
+                    // In this case, page 0 doesn't exist as a bookmarking or sharing option, so nothing else to add.
+                }
+
+            } elseif ($page_number % 2 === 0) {
+                // Only stats for even pages are considered when grouped (except for bookmarks / shares)
+                $following_page = $page_number + 1; // By logic, there should always be a following page
+                $page_group = $page_number .'—'. $following_page;
+
+                $pages[$page_group] = [
+                    'page_group' => $page_group,
+                    'page_number'  => $page_number, // Used by table column sorter
+                    'nb_visits' => $pageUrls["/page/$page_number"]['nb_visits'] ?? 0,
+                    'nb_pageviews' => $pageUrls["/page/$page_number"]['nb_hits'] ?? 0,
+                    'nb_zooms' => data_get($eventsByPage, "zoom.subtable.$page_number.nb_events", 0),
+                    // Bookmarks and shares are counted and summed for both pages in the spread
+                    'nb_bookmarks' => data_get($eventsByPage, "bookmark.subtable.$page_number.nb_events", 0) + data_get($eventsByPage, "bookmark.subtable.$following_page.nb_events", 0),
+                    'nb_shares' => data_get($eventsByPage, "share.subtable.$page_number.nb_events", 0) + data_get($eventsByPage, "share.subtable.$following_page.nb_events", 0),
+                ];
+            }
+        }
 
-        $report = $this->_getReporting($fluidbook_id);
+        //=== SEARCH KEYWORDS
+        // Get the search keywords as a range because we don't need to display them by date
+        // Note: we're using 'nb_hits' as the measurement for the number of searches instead
+        // of 'nb_visits'. This makes most sense but there was a bug causing double hits to be
+        // recorded for a certain period (see: https://redmine.cubedesigners.com/issues/5435)
+        $searches = collect($report->getSearchKeywords(['period' => 'range']))
+            ->sortByDesc('nb_hits') // Sort in descending order based on the nb_hits value
+            ->pluck('nb_hits', 'label'); // Simplify result to only contain nb_hits, keyed by the label
+
+        //=== OUTGOING LINKS
+        // There's a separate Actions.getOutlinks API endpoint, but we're not using it because we already have the
+        // summarised outgoing link statistics in the $eventsByPage collection (even though link events aren't
+        // specifically associated with a page number, the API returns this data when gathering the other events)
+        $outlinks = collect($eventsByPage['link']['subtable'] ?? [])->sortByDesc('nb_events');
+
+        //=== COUNTRIES OF ORIGIN
+        // Get visitor countries for the full range and sort by visitor numbers
+        // Note: for some reason, Matomo sometimes records duplicate countries. The label is the same but sometimes
+        // the country code is in uppercase (ie. "FR" instead of "fr"), which causes the flag to show as unknown.
+        // To address this, we need to group countries by their labels and sum the values from duplicates:
+        $countries = [];
+        $stats_server = $report->getServerUrl();
+        $flags_base_URL = $stats_server . 'plugins/Morpheus/icons/dist/flags/';
+
+        foreach ($report->getCountries(['period' => 'range']) as $country) {
+            $country_code = strtolower($country['code']);
+
+            if (isset($countries[$country_code])) {
+                $countries[$country_code]['nb_visits'] += $country['nb_visits'];
+            } else {
+                $countries[$country_code]['label'] = $country['label'];
+                $countries[$country_code]['nb_visits'] = $country['nb_visits'];
+                // Set the flag URL based on the stats server. Normally we could use the $country['logo'] value but
+                // sometimes this has an "unknown" value of xx.png because the country code was uppercase. As such,
+                // we have to construct our own URL. This should be fine, as long as the Matomo flag directory
+                // structure doesn't change in the future...
+                $countries[$country_code]['flag'] = $flags_base_URL . $country_code . '.png';
+            }
+        }
+        $countries = collect($countries)->sortByDesc('nb_visits');
 
-        // echo "Getting stats for date range $date_range, segregated by $period";
-        //dump(collect($report->getVisits($fluidbook_id, $date_range, $period))->sum('nb_visits'));
-        //dump($report->getVisits($fluidbook_id, $date_range, 'range'));
-        // dd($report->getVisits($fluidbook_id, $date_range, $period));
-        $visits = collect($report->getVisits($fluidbook_id, $date_range, $period));
-        $pageviews = collect($report->getPageViews($fluidbook_id, $date_range, $period));
 
-        // Get the search keywords as a range because we don't need to display them by date
-        $searches = collect($report->getSearchKeywords($fluidbook_id, $date_range, 'range'));
+        //=== MAIN PERIOD STATISTICS
+        // These are the main statistics used to build the chart and the table below it.
+        // Statistics are segregated by a certain period (eg. years, months or days)
+        $pagesByPeriod = collect($report->getPageUrls())
+            ->map(function ($item, $date) use ($period, $fluidbook_id, $hash, $eventsByPeriod) {
 
+            if (empty($item)) {
+                return $item; // Some periods might have no data
+            }
+
+            // Key results by their label to make it easier to isolate the "page" stats
+            // More specifically, we want to get rid of the "/index" data because these aren't true page views
+            $labelled = collect($item)->keyBy('label');
+
+            if (!isset($labelled['page'])) {
+                // If there's are no page stats, we treat it is if there's no data for this period
+                // This is necessary because it's possible that there will be an '/index' item but no 'page'
+                return [];
+            }
+
+            // By returning the page data directly (if available), it makes
+            // the returned data flatter and easier to process later for sums etc.
+            $data = $labelled['page'];
+
+            $data['raw_date'] = $date; // We still need the raw date for sorting and other formatting purposes
+
+            // Formatting of date changes depending on the period
+            $formatted_date = $this->formatDateForPeriod($date, $period);
+
+            // When segregated by month, the periods are links to the detailed month view
+            if ($period === 'month') {
+                // Generate URL using the named route
+                $URL = route('stats', compact('fluidbook_id', 'hash', 'date'));
+                $data['formatted_date'] = "<a href='{$URL}'>{$formatted_date}</a>";
+            } else {
+                $data['formatted_date'] = $formatted_date;
+            }
+
+            // Add certain event category data
+            $period_events = $eventsByPeriod->get($date);
+            $data['nb_zooms'] = data_get($period_events, 'zoom.nb_events', 0);
+            $data['nb_shares'] = data_get($period_events, 'share.nb_events', 0);
+            $data['nb_links'] = data_get($period_events, 'link.nb_events', 0); // Used instead of standard outlinks count
+
+            // Download and Print events are stored differently and can't be changed now
+            // (see https://redmine.cubedesigners.com/issues/5431#note-2)
+            // If 'menu' event exists, collect and key it by label so we can use data_get() for cleaner access
+            $menu_events = $period_events->has('menu') ? collect($period_events['menu']['subtable'])->keyBy('label') : [];
+            $data['nb_downloads'] = data_get($menu_events, 'download.nb_events', 0);
+            $data['nb_prints'] = data_get($menu_events, 'print.nb_events', 0);
+
+            return $data;
+        });
+
+        //=== CHART PREPARATION
         // Format dates for display as labels on the x-axis
-        $labels = $visits->keys()->map(function ($label, $index) use ($period, $mode) {
-            return match ($period) {
-                'day' => Carbon::parse($label)->isoFormat('DD'), // Convert YYYY-MM-DD string from API into zero-padded day alone
-                'month' => $mode === 'overview' ? Carbon::parse($label)->isoFormat('MMM YYYY') : Carbon::parse($label)->isoFormat('MMM'), // Convert to abbreviated month name (including year when showing all months)
-                default => $label,
-            };
+        $labels = $pagesByPeriod->keys()->map(function ($label, $index) use ($period, $mode) {
+            return $this->formatDateForXAxis($label, $period, $mode);
         })->toArray();
 
         // Format dates for display in the tooltip title
-        $formatted_dates = $visits->keys()->map(function ($label, $index) use ($period) {
+        $formatted_dates = $pagesByPeriod->keys()->map(function ($label, $index) use ($period) {
             return $this->formatDateForPeriod($label, $period);
         })->toArray();
 
         $tooltip_labels = array_combine($labels, $formatted_dates);
 
-        // Generate a list of available periods, based on the visits API data
+        //=== SUMMARY BY PERIOD
+        // Generate a list of available periods, based on the visits API data.
+        // If there are no visits for a certain period, we can assume that there won't be any data for other metrics.
         // The goal is to find the non-empty results and create a list of Carbon date classes from those
-        // This list is used to generate the links / formatted dates list
-        $available_periods = $visits->filter(fn ($value, $key) => !empty($value)) // First, remove empty values
-                                    ->map(function ($item, $key) use ($period, $fluidbook_id, $hash) { // Add new key to data for formatted date
-                                        $date = $key;
-                                        $item['formatted_date'] = $this->formatDateForPeriod($key, $period); // Formatting depends on the period
+        // This list is used to generate the links / formatted dates list + detailed data table
+        $period_details = $pagesByPeriod->filter(fn ($value, $key) => !empty($value)); // Remove any empty periods
+
+        // Map of API data to table headings (used to display summaries under the chart)
+        $table_map = [
+            // Main summary table
+            'summary' => [
+                'formatted_date' => __('Period'),
+                'nb_visits' => __('Visits'),
+                'nb_hits' => __('Pages Viewed'),
+                'nb_links' => __('Outgoing Links'),
+                'nb_downloads' => __('Downloads'),
+                'nb_prints' => __('Prints'),
+                'nb_zooms' => __('Zooms'),
+            ],
+            // Per-page detail table
+            'per-page' => [
+                'page_group' => __('Pages'),
+                'nb_visits' => __('Visits'),
+                'nb_pageviews' => __('Views'),
+                'nb_zooms' => __('Zooms'),
+                'nb_bookmarks' => __('Bookmarks'),
+                'nb_shares' => __('Shares'),
+            ],
+        ];
+
+        $formatter = NumberFormatter::create($locale, NumberFormatter::DEFAULT_STYLE);
+
+        return view('fluidbook_stats.summary',
+            compact(
+                'fluidbook',
+                'start_date',
+                'end_date',
+                'report_timespan',
+                'page_count',
+                'labels',
+                'tooltip_labels',
+                'formatted_date_range',
+                'chart_heading',
+                'searches',
+                'outlinks',
+                'countries',
+                'mode',
+                'period',
+                'dates',
+                'period_details',
+                'table_map',
+                'pages',
+                'pagesByPeriod',
+                'formatter',
+                'locale',
+                'base_URL',
+            )
+        );
+
+    }
 
-                                        if ($period === 'month') {
-                                            $item['URL'] = route('stats', compact('fluidbook_id', 'hash', 'date')); // Generate URL using named route
-                                        }
+    // Redirect users with sufficient permissions to the full hashed URL
+    // This was added as a convenience when quickly switching between stats views for Fluidbooks
+    // If the current user has sufficient permissions, they will be redirected to the hashed URL.
+    // If not, the FluidbookPublication lookup will fail and further execution will halt.
+    protected function statsRedirect($fluidbook_id) {
+        $fluidbook = FluidbookPublication::where('id', $fluidbook_id)->firstOrFail();
 
-                                        return $item;
-                                    });
+        $settings = json_decode($fluidbook->settings);
+        $hash = $fluidbook->hash;
 
-        return view('fluidbook_stats.summary', compact('fluidbook', 'labels', 'tooltip_labels', 'visits', 'pageviews', 'searches', 'mode', 'period', 'dates', 'available_periods'));
+        if (!$fluidbook->stats) {
+            return "Statistics are disabled for this Fluidbook #{$fluidbook_id} ({$fluidbook->name})";
+        }
 
+        return redirect()->route('stats', compact('fluidbook_id', 'hash'));
     }
 
-    protected function statsAPI()
-    {
+    // https://toolbox.fluidbook.com/fluidbook-publication/stats/API
+    protected function statsAPI() {
 
         if (!can('superadmin')) {
-            // Only allow superadmin access because this is a dev tool and it exposes the API tokens
+            // Only allow superadmin access because this is a dev tool, and it exposes the API tokens
             return response(null)->setStatusCode('403');
         }
 
-        $matomo_tokens = json_encode(static::getMatomoTokens());
-
+        $matomo_tokens = json_encode($this->getMatomoTokens());
         return view('fluidbook_stats.API', compact('matomo_tokens'));
     }
 
-    protected function formatDateForPeriod($date, $period) {
+    // Format dates depending on the segregation period (used for tooltips and stats detail tables)
+    protected function formatDateForPeriod($date, $period): string {
         return match ($period) {
-            'day' => Carbon::parse($date)->isoFormat('dddd Do MMMM YYYY'),
+            'day' => Carbon::parse($date)->isoFormat('dddd, Do MMMM YYYY'),
             'month' => Carbon::parse($date)->isoFormat('MMMM YYYY'),
             default => $date,
         };
     }
 
+    // Dates displayed in the x-axis of chart need different formatting
+    protected function formatDateForXAxis($date, $period, $mode): string {
+        return match ($period) {
+            'day' => Carbon::parse($date)->isoFormat('DD'), // Convert YYYY-MM-DD string from API into zero-padded day alone
+            'month' => $mode === 'overview' ? Carbon::parse($date)->isoFormat('MMM YYYY') : Carbon::parse($date)->isoFormat('MMM'), // Convert to abbreviated month name (including year when showing all months)
+            default => $date,
+        };
+    }
+
 }
index c63001ddd7bf67bab6665e22786ac94f21664f53..2856bc2e4b6956fab84b8581dfafb7bdbbb6ceec 100644 (file)
@@ -21,6 +21,7 @@
         "php": ">=8.1",
         "ext-calendar": "*",
         "ext-dom": "*",
+        "ext-intl": "*",
         "ext-json": "*",
         "ext-libxml": "*",
         "ext-simplexml": "*",
diff --git a/public/packages/daterangepicker/daterangepicker.css b/public/packages/daterangepicker/daterangepicker.css
new file mode 100755 (executable)
index 0000000..3ca56fc
--- /dev/null
@@ -0,0 +1,502 @@
+/*!
+ * knockout-daterangepicker
+ * version: 0.1.0
+ * authors: Sensor Tower team
+ * license: MIT
+ * https://sensortower.github.io/daterangepicker
+ */
+.daterangepicker {
+  display: none;
+  position: absolute;
+  background: white;
+  box-shadow: 0 2px 15px rgba(0, 0, 0, 0.3);
+  -ms-flex-pack: start;
+      justify-content: flex-start;
+  border-radius: 4px;
+  padding: 4px;
+  font-size: 13px;
+  font-family: sans-serif;
+  line-height: 1.5em;
+}
+
+.daterangepicker ul, .daterangepicker li, .daterangepicker button, .daterangepicker form {
+  padding: 0;
+  margin: 0;
+  border: 0;
+  list-style: none;
+  outline: none;
+}
+
+.daterangepicker .controls {
+  min-width: 180px;
+  margin: 4px;
+}
+
+.daterangepicker .periods li,
+.daterangepicker .ranges li {
+  margin: 0;
+  padding: 4px 9px;
+  margin: 0;
+  background: #f5f5f5;
+  color: #08c;
+  cursor: pointer;
+}
+
+.daterangepicker .periods li:hover, .daterangepicker .periods li.active,
+.daterangepicker .ranges li:hover,
+.daterangepicker .ranges li.active {
+  background: #08c;
+  color: white;
+}
+
+.daterangepicker .periods {
+  display: -ms-inline-flexbox;
+  display: inline-flex;
+  margin: 0 auto 8px;
+}
+
+.daterangepicker .periods li:first-child {
+  border-radius: 4px 0 0 4px;
+}
+
+.daterangepicker .periods li:last-child {
+  border-radius: 0 4px 4px 0;
+}
+
+.daterangepicker .ranges {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-direction: column;
+      flex-direction: column;
+  -ms-flex-align: stretch;
+      align-items: stretch;
+}
+
+.daterangepicker .ranges li {
+  border-radius: 4px;
+  margin-bottom: 8px;
+  text-align: left;
+}
+
+.daterangepicker .custom-range-inputs {
+  display: -ms-flexbox;
+  display: flex;
+  margin: -3px;
+  margin-bottom: 5px;
+}
+
+.daterangepicker .custom-range-inputs input {
+  min-width: 50px;
+  width: 50px;
+  -ms-flex: 1;
+      flex: 1;
+  margin: 3px;
+  border-radius: 4px;
+  border: 1px solid #ccc;
+  height: auto;
+  padding: 0.5em;
+  font-size: 13px;
+  color: #333;
+}
+
+.daterangepicker .custom-range-buttons {
+  display: -ms-flexbox;
+  display: flex;
+  margin: -3px;
+}
+
+.daterangepicker .custom-range-buttons button {
+  margin: 0;
+  padding: 4px 9px;
+  margin: 3px;
+  border-radius: 4px;
+  background: #f5f5f5;
+  color: #08c;
+}
+
+.daterangepicker .custom-range-buttons button:hover {
+  background: gainsboro;
+}
+
+.daterangepicker .custom-range-buttons button.apply-btn {
+  background: #38A551;
+  color: white;
+}
+
+.daterangepicker .custom-range-buttons button.apply-btn:hover {
+  background: #2b7f3e;
+}
+
+.daterangepicker .arrow-left,
+.daterangepicker .arrow-right {
+  display: inline-block;
+  position: relative;
+  background-color: #333;
+  width: 7px;
+  height: 3px;
+  margin-bottom: 2px;
+  vertical-align: middle;
+}
+
+.daterangepicker .arrow-left:before,
+.daterangepicker .arrow-right:before {
+  content: '';
+  display: block;
+  position: absolute;
+  border: 5px solid transparent;
+}
+
+.daterangepicker .arrow-left {
+  margin-left: 5px;
+}
+
+.daterangepicker .arrow-left:before {
+  border-right-width: 6px;
+  border-right-color: #333;
+  transform: translate(-10px, -3.5px);
+}
+
+.daterangepicker .arrow-right {
+  margin-right: 5px;
+}
+
+.daterangepicker .arrow-right:before {
+  border-left-width: 6px;
+  border-left-color: #333;
+  transform: translate(6px, -3.5px);
+}
+
+.daterangepicker.orientation-right:not(.standalone):before {
+  position: absolute;
+  top: -7px;
+  left: 9px;
+  display: inline-block;
+  border-right: 7px solid transparent;
+  border-bottom: 7px solid rgba(0, 0, 0, 0.2);
+  border-left: 7px solid transparent;
+  content: '';
+}
+
+.daterangepicker.orientation-left:not(.standalone):before {
+  position: absolute;
+  top: -7px;
+  right: 9px;
+  display: inline-block;
+  border-right: 7px solid transparent;
+  border-bottom: 7px solid rgba(0, 0, 0, 0.2);
+  border-left: 7px solid transparent;
+  content: '';
+}
+
+.daterangepicker.orientation-right:not(.standalone):after {
+  position: absolute;
+  top: -6px;
+  left: 10px;
+  display: inline-block;
+  border-right: 6px solid transparent;
+  border-bottom: 6px solid #fff;
+  border-left: 6px solid transparent;
+  content: '';
+}
+
+.daterangepicker.orientation-left:not(.standalone):after {
+  position: absolute;
+  top: -6px;
+  right: 10px;
+  display: inline-block;
+  border-right: 6px solid transparent;
+  border-bottom: 6px solid #fff;
+  border-left: 6px solid transparent;
+  content: '';
+}
+
+.daterangepicker select {
+  width: 100%;
+  box-sizing: border-box;
+  padding: 2px 7px;
+  height: auto;
+  font-size: 13px;
+  line-height: 1.5em;
+  text-align: center;
+  margin: 0 2px;
+}
+
+.daterangepicker select.hidden {
+  display: none;
+}
+
+.daterangepicker select.month-select {
+  -ms-flex: 10;
+      flex: 10;
+  max-width: 75%;
+}
+
+.daterangepicker select.year-select {
+  -ms-flex: 11;
+      flex: 11;
+  max-width: 75%;
+}
+
+.daterangepicker select.decade-select {
+  -ms-flex: 11;
+      flex: 11;
+  max-width: 75%;
+}
+
+.calendar {
+  display: none;
+  margin: 4px;
+}
+
+.calendar .calendar-header,
+.calendar .calendar-table {
+  min-width: 190px;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.calendar .calendar-title {
+  margin: 0;
+  padding: 4px 9px;
+  margin: 0 auto;
+  margin-bottom: 8px;
+  text-align: center;
+  display: block;
+}
+
+.calendar .calendar-header button {
+  margin: 0;
+  padding: 4px 9px;
+  width: 100%;
+  padding-left: 0;
+  padding-right: 0;
+  border-radius: 4px;
+  background: transparent;
+}
+
+.calendar .calendar-header button:hover {
+  background: #f5f5f5;
+}
+
+.calendar .calendar-header {
+  display: -ms-flexbox;
+  display: flex;
+  margin: 0 6px 4px;
+}
+
+.calendar .calendar-header .calendar-selects {
+  -ms-flex: 5;
+      flex: 5;
+  text-align: center;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-pack: center;
+      justify-content: center;
+  -ms-flex-align: center;
+      align-items: center;
+  padding: 2px;
+}
+
+.calendar .calendar-header .arrow {
+  -ms-flex: 1;
+      flex: 1;
+  text-align: center;
+}
+
+.calendar .calendar-header .arrow.arrow-hidden {
+  visibility: hidden;
+}
+
+.calendar .calendar-table {
+  height: 180px;
+  border: 1px solid #f5f5f5;
+  border-radius: 4px;
+  overflow: hidden;
+  padding: 5px;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-line-pack: stretch;
+      align-content: stretch;
+  -ms-flex-pack: distribute;
+      justify-content: space-around;
+  -ms-flex-direction: column;
+      flex-direction: column;
+}
+
+.calendar .calendar-table .table-row {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-line-pack: stretch;
+      align-content: stretch;
+  -ms-flex-pack: distribute;
+      justify-content: space-around;
+  -ms-flex: 1;
+      flex: 1;
+}
+
+.calendar .calendar-table .table-row .table-col {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex: 1;
+      flex: 1;
+  text-align: center;
+  line-height: 1;
+  cursor: default;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+}
+
+.calendar .calendar-table .table-row .table-col .table-value-wrapper {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex: 1;
+      flex: 1;
+  -ms-flex-align: center;
+      align-items: center;
+}
+
+.calendar .calendar-table .table-row .table-col .table-value-wrapper .table-value {
+  -ms-flex: 1;
+      flex: 1;
+}
+
+.calendar .calendar-table .table-row .table-col.out-of-boundaries, .calendar .calendar-table .table-row .table-col.unavailable,
+.calendar .calendar-table .table-row .table-col .week-day.unavailable {
+  color: #bbb;
+}
+
+.calendar .calendar-table .table-row .table-col.in-range {
+  background: rgba(0, 136, 204, 0.1);
+}
+
+.calendar .calendar-table .table-row .table-col.clickable {
+  cursor: pointer;
+}
+
+.calendar .calendar-table .table-row .table-col.clickable:hover .table-value-wrapper {
+  background: #eee;
+  border-radius: 4px;
+}
+
+.calendar .calendar-table .table-row .table-col.start-date .table-value-wrapper, .calendar .calendar-table .table-row .table-col.end-date .table-value-wrapper {
+  border-radius: 4px;
+}
+
+.calendar .calendar-table .table-row .table-col.start-date .table-value-wrapper, .calendar .calendar-table .table-row .table-col.start-date .table-value-wrapper:hover, .calendar .calendar-table .table-row .table-col.end-date .table-value-wrapper, .calendar .calendar-table .table-row .table-col.end-date .table-value-wrapper:hover {
+  background: #08c;
+  color: white;
+}
+
+.calendar .calendar-table .table-row .table-col.start-date.out-of-boundaries .table-value-wrapper, .calendar .calendar-table .table-row .table-col.start-date.out-of-boundaries .table-value-wrapper:hover, .calendar .calendar-table .table-row .table-col.end-date.out-of-boundaries .table-value-wrapper, .calendar .calendar-table .table-row .table-col.end-date.out-of-boundaries .table-value-wrapper:hover {
+  background: #bbb;
+}
+
+.calendar .calendar-table .table-row .table-col.start-date {
+  border-radius: 4px 0 0 4px;
+}
+
+.calendar .calendar-table .table-row .table-col.end-date {
+  border-radius: 0 4px 4px 0;
+}
+
+.calendar .calendar-table .table-row .table-col .week-day {
+  -ms-flex: 1;
+      flex: 1;
+  text-align: center;
+}
+
+.calendar .calendar-table .table-row.weekdays .table-col {
+  font-weight: bold;
+}
+
+.daterangepicker.opened {
+  display: -ms-inline-flexbox;
+  display: inline-flex;
+}
+
+.daterangepicker.expanded .calendar {
+  display: block;
+}
+
+.daterangepicker.hide-periods .periods {
+  display: none;
+}
+
+.daterangepicker.hide-periods .calendar .calendar-title {
+  display: none;
+}
+
+.daterangepicker.standalone {
+  position: static;
+}
+
+.daterangepicker.standalone .custom-range-buttons {
+  display: none;
+}
+
+.daterangepicker.hide-weekdays .weekdays {
+  display: none;
+}
+
+.daterangepicker.single {
+  -ms-flex-direction: column;
+      flex-direction: column;
+}
+
+.daterangepicker.single .ranges,
+.daterangepicker.single .custom-range-inputs,
+.daterangepicker.single .custom-range-buttons,
+.daterangepicker.single .calendar .calendar-title {
+  display: none;
+}
+
+.daterangepicker.single .controls {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-pack: center;
+      justify-content: center;
+}
+
+.daterangepicker.single .controls .periods {
+  margin-bottom: 0;
+}
+
+.daterangepicker.single .calendar .calendar-header {
+  margin-left: 0;
+  margin-right: 0;
+}
+
+.daterangepicker.single .calendar .calendar-table {
+  border: none;
+  padding: 0;
+}
+
+.daterangepicker.single.hide-periods .controls {
+  display: none;
+}
+
+.daterangepicker.month-period .table-col {
+  font-size: 1.25em;
+}
+
+.daterangepicker.year-period .table-col {
+  font-size: 1.25em;
+}
+
+.daterangepicker.quarter-period .table-col {
+  -ms-flex-direction: column;
+      flex-direction: column;
+  font-size: 2em;
+}
+
+.daterangepicker.quarter-period .table-col .months {
+  font-size: 0.5em;
+  opacity: 0.75;
+}
+
+.daterangepicker.orientation-left:not(.single) .controls {
+  -ms-flex-order: 2;
+      order: 2;
+}
diff --git a/public/packages/daterangepicker/daterangepicker.js b/public/packages/daterangepicker/daterangepicker.js
new file mode 100755 (executable)
index 0000000..721791f
--- /dev/null
@@ -0,0 +1,1207 @@
+/*!
+ * knockout-daterangepicker
+ * version: 0.1.0
+ * authors: Sensor Tower team
+ * license: MIT
+ * https://sensortower.github.io/daterangepicker
+ */
+(function() {
+  var AllTimeDateRange, ArrayUtils, CalendarHeaderView, CalendarView, Config, CustomDateRange, DateRange, DateRangePickerView, MomentIterator, MomentUtil, Period,
+    extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
+    hasProp = {}.hasOwnProperty,
+    bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
+
+  MomentUtil = (function() {
+    function MomentUtil() {}
+
+    MomentUtil.patchCurrentLocale = function(obj) {
+      return moment.locale(moment.locale(), obj);
+    };
+
+    MomentUtil.setFirstDayOfTheWeek = function(dow) {
+      var offset;
+      dow = (dow % 7 + 7) % 7;
+      if (moment.localeData().firstDayOfWeek() !== dow) {
+        offset = dow - moment.localeData().firstDayOfWeek();
+        return this.patchCurrentLocale({
+          week: {
+            dow: dow,
+            doy: moment.localeData().firstDayOfYear()
+          }
+        });
+      }
+    };
+
+    MomentUtil.tz = function(input) {
+      var args, timeZone;
+      args = Array.prototype.slice.call(arguments, 0, -1);
+      timeZone = arguments[arguments.length - 1];
+      if (moment.tz) {
+        return moment.tz.apply(null, args.concat([timeZone]));
+      } else if (timeZone && timeZone.toLowerCase() === 'utc') {
+        return moment.utc.apply(null, args);
+      } else {
+        return moment.apply(null, args);
+      }
+    };
+
+    return MomentUtil;
+
+  })();
+
+  MomentIterator = (function() {
+    MomentIterator.array = function(date, amount, period) {
+      var i, iterator, j, ref, results;
+      iterator = new this(date, period);
+      results = [];
+      for (i = j = 0, ref = amount - 1; 0 <= ref ? j <= ref : j >= ref; i = 0 <= ref ? ++j : --j) {
+        results.push(iterator.next());
+      }
+      return results;
+    };
+
+    function MomentIterator(date, period) {
+      this.date = date.clone();
+      this.period = period;
+    }
+
+    MomentIterator.prototype.next = function() {
+      var nextDate;
+      nextDate = this.date;
+      this.date = nextDate.clone().add(1, this.period);
+      return nextDate.clone();
+    };
+
+    return MomentIterator;
+
+  })();
+
+  ArrayUtils = (function() {
+    function ArrayUtils() {}
+
+    ArrayUtils.rotateArray = function(array, offset) {
+      offset = offset % array.length;
+      return array.slice(offset).concat(array.slice(0, offset));
+    };
+
+    ArrayUtils.uniqArray = function(array) {
+      var i, j, len, newArray;
+      newArray = [];
+      for (j = 0, len = array.length; j < len; j++) {
+        i = array[j];
+        if (newArray.indexOf(i) === -1) {
+          newArray.push(i);
+        }
+      }
+      return newArray;
+    };
+
+    return ArrayUtils;
+
+  })();
+
+  $.fn.daterangepicker = function(options, callback) {
+    if (options == null) {
+      options = {};
+    }
+    this.each(function() {
+      var $element;
+      $element = $(this);
+      if (!$element.data('daterangepicker')) {
+        options.anchorElement = $element;
+        if (callback) {
+          options.callback = callback;
+        }
+        options.callback = $.proxy(options.callback, this);
+        return $element.data('daterangepicker', new DateRangePickerView(options));
+      }
+    });
+    return this;
+  };
+
+  ko.bindingHandlers.stopBinding = {
+    init: function() {
+      return {
+        controlsDescendantBindings: true
+      };
+    }
+  };
+
+  ko.virtualElements.allowedBindings.stopBinding = true;
+
+  ko.bindingHandlers.daterangepicker = (function() {
+    return $.extend(this, {
+      _optionsKey: 'daterangepickerOptions',
+      _formatKey: 'daterangepickerFormat',
+      init: function(element, valueAccessor, allBindings) {
+        var observable, options;
+        observable = valueAccessor();
+        options = ko.unwrap(allBindings.get(this._optionsKey)) || {};
+        return $(element).daterangepicker(options, function(startDate, endDate, period) {
+          return observable([startDate, endDate]);
+        });
+      },
+      update: function(element, valueAccessor, allBindings) {
+        var $element, dateFormat, endDate, endDateText, ref, startDate, startDateText;
+        $element = $(element);
+        ref = valueAccessor()(), startDate = ref[0], endDate = ref[1];
+        dateFormat = ko.unwrap(allBindings.get(this._formatKey)) || 'MMM D, YYYY';
+        startDateText = moment(startDate).format(dateFormat);
+        endDateText = moment(endDate).format(dateFormat);
+        return ko.ignoreDependencies(function() {
+          var text;
+          if (!$element.data('daterangepicker').standalone()) {
+            text = $element.data('daterangepicker').single() ? startDateText : startDateText + " – " + endDateText;
+            $element.val(text).text(text);
+          }
+          $element.data('daterangepicker').startDate(startDate);
+          return $element.data('daterangepicker').endDate(endDate);
+        });
+      }
+    });
+  })();
+
+  DateRange = (function() {
+    function DateRange(title, startDate, endDate) {
+      this.title = title;
+      this.startDate = startDate;
+      this.endDate = endDate;
+    }
+
+    return DateRange;
+
+  })();
+
+  AllTimeDateRange = (function(superClass) {
+    extend(AllTimeDateRange, superClass);
+
+    function AllTimeDateRange() {
+      return AllTimeDateRange.__super__.constructor.apply(this, arguments);
+    }
+
+    return AllTimeDateRange;
+
+  })(DateRange);
+
+  CustomDateRange = (function(superClass) {
+    extend(CustomDateRange, superClass);
+
+    function CustomDateRange() {
+      return CustomDateRange.__super__.constructor.apply(this, arguments);
+    }
+
+    return CustomDateRange;
+
+  })(DateRange);
+
+  Period = (function() {
+    function Period() {}
+
+    Period.allPeriods = ['day', 'week', 'month', 'quarter', 'year'];
+
+    Period.scale = function(period) {
+      if (period === 'day' || period === 'week') {
+        return 'month';
+      } else {
+        return 'year';
+      }
+    };
+
+    Period.showWeekDayNames = function(period) {
+      if (period === 'day' || period === 'week') {
+        return true;
+      } else {
+        return false;
+      }
+    };
+
+    Period.nextPageArguments = function(period) {
+      var amount, scale;
+      amount = period === 'year' ? 9 : 1;
+      scale = this.scale(period);
+      return [amount, scale];
+    };
+
+    Period.format = function(period) {
+      switch (period) {
+        case 'day':
+        case 'week':
+          return 'D';
+        case 'month':
+          return 'MMM';
+        case 'quarter':
+          return '\\QQ';
+        case 'year':
+          return 'YYYY';
+      }
+    };
+
+    Period.title = function(period) {
+      switch (period) {
+        case 'day':
+          return 'Day';
+        case 'week':
+          return 'Week';
+        case 'month':
+          return 'Month';
+        case 'quarter':
+          return 'Quarter';
+        case 'year':
+          return 'Year';
+      }
+    };
+
+    Period.dimentions = function(period) {
+      switch (period) {
+        case 'day':
+          return [7, 6];
+        case 'week':
+          return [1, 6];
+        case 'month':
+          return [3, 4];
+        case 'quarter':
+          return [2, 2];
+        case 'year':
+          return [3, 3];
+      }
+    };
+
+    Period.methods = ['scale', 'showWeekDayNames', 'nextPageArguments', 'format', 'title', 'dimentions'];
+
+    Period.extendObservable = function(observable) {
+      this.methods.forEach(function(method) {
+        return observable[method] = function() {
+          return Period[method](observable());
+        };
+      });
+      return observable;
+    };
+
+    return Period;
+
+  })();
+
+  Config = (function() {
+    function Config(options) {
+      if (options == null) {
+        options = {};
+      }
+      this.firstDayOfWeek = this._firstDayOfWeek(options.firstDayOfWeek);
+      this.timeZone = this._timeZone(options.timeZone);
+      this.periods = this._periods(options.periods);
+      this.customPeriodRanges = this._customPeriodRanges(options.customPeriodRanges);
+      this.period = this._period(options.period);
+      this.single = this._single(options.single);
+      this.opened = this._opened(options.opened);
+      this.expanded = this._expanded(options.expanded);
+      this.standalone = this._standalone(options.standalone);
+      this.hideWeekdays = this._hideWeekdays(options.hideWeekdays);
+      this.locale = this._locale(options.locale);
+      this.orientation = this._orientation(options.orientation);
+      this.forceUpdate = options.forceUpdate;
+      this.minDate = this._minDate(options.minDate);
+      this.maxDate = this._maxDate(options.maxDate);
+      this.startDate = this._startDate(options.startDate);
+      this.endDate = this._endDate(options.endDate);
+      this.ranges = this._ranges(options.ranges);
+      this.isCustomPeriodRangeActive = ko.observable(false);
+      this.anchorElement = this._anchorElement(options.anchorElement);
+      this.parentElement = this._parentElement(options.parentElement);
+      this.callback = this._callback(options.callback);
+      this.firstDayOfWeek.subscribe(function(newValue) {
+        return MomentUtil.setFirstDayOfTheWeek(newValue);
+      });
+      MomentUtil.setFirstDayOfTheWeek(this.firstDayOfWeek());
+    }
+
+    Config.prototype.extend = function(obj) {
+      var k, ref, results, v;
+      ref = this;
+      results = [];
+      for (k in ref) {
+        v = ref[k];
+        if (this.hasOwnProperty(k) && k[0] !== '_') {
+          results.push(obj[k] = v);
+        }
+      }
+      return results;
+    };
+
+    Config.prototype._firstDayOfWeek = function(val) {
+      return ko.observable(val ? val : 0);
+    };
+
+    Config.prototype._timeZone = function(val) {
+      return ko.observable(val || 'UTC');
+    };
+
+    Config.prototype._periods = function(val) {
+      return ko.observableArray(val || Period.allPeriods);
+    };
+
+    Config.prototype._customPeriodRanges = function(obj) {
+      var results, title, value;
+      obj || (obj = {});
+      results = [];
+      for (title in obj) {
+        value = obj[title];
+        results.push(this.parseRange(value, title));
+      }
+      return results;
+    };
+
+    Config.prototype._period = function(val) {
+      val || (val = this.periods()[0]);
+      if (val !== 'day' && val !== 'week' && val !== 'month' && val !== 'quarter' && val !== 'year') {
+        throw new Error('Invalid period');
+      }
+      return Period.extendObservable(ko.observable(val));
+    };
+
+    Config.prototype._single = function(val) {
+      return ko.observable(val || false);
+    };
+
+    Config.prototype._opened = function(val) {
+      return ko.observable(val || false);
+    };
+
+    Config.prototype._expanded = function(val) {
+      return ko.observable(val || false);
+    };
+
+    Config.prototype._standalone = function(val) {
+      return ko.observable(val || false);
+    };
+
+    Config.prototype._hideWeekdays = function(val) {
+      return ko.observable(val || false);
+    };
+
+    Config.prototype._minDate = function(val) {
+      var mode, ref;
+      if (val instanceof Array) {
+        ref = val, val = ref[0], mode = ref[1];
+      }
+      val || (val = moment().subtract(30, 'years'));
+      return this._dateObservable(val, mode);
+    };
+
+    Config.prototype._maxDate = function(val) {
+      var mode, ref;
+      if (val instanceof Array) {
+        ref = val, val = ref[0], mode = ref[1];
+      }
+      val || (val = moment());
+      return this._dateObservable(val, mode, this.minDate);
+    };
+
+    Config.prototype._startDate = function(val) {
+      val || (val = moment().subtract(29, 'days'));
+      return this._dateObservable(val, null, this.minDate, this.maxDate);
+    };
+
+    Config.prototype._endDate = function(val) {
+      val || (val = moment());
+      return this._dateObservable(val, null, this.startDate, this.maxDate);
+    };
+
+    Config.prototype._ranges = function(obj) {
+      var results, title, value;
+      obj || (obj = this._defaultRanges());
+      if (!$.isPlainObject(obj)) {
+        throw new Error('Invalid ranges parameter (should be a plain object)');
+      }
+      results = [];
+      for (title in obj) {
+        value = obj[title];
+        switch (value) {
+          case 'all-time':
+            results.push(new AllTimeDateRange(title, this.minDate().clone(), this.maxDate().clone()));
+            break;
+          case 'custom':
+            results.push(new CustomDateRange(title));
+            break;
+          default:
+            results.push(this.parseRange(value, title));
+        }
+      }
+      return results;
+    };
+
+    Config.prototype.parseRange = function(value, title) {
+      var endDate, from, startDate, to;
+      if (!$.isArray(value)) {
+        throw new Error('Value should be an array');
+      }
+      startDate = value[0], endDate = value[1];
+      if (!startDate) {
+        throw new Error('Missing start date');
+      }
+      if (!endDate) {
+        throw new Error('Missing end date');
+      }
+      from = MomentUtil.tz(startDate, this.timeZone());
+      to = MomentUtil.tz(endDate, this.timeZone());
+      if (!from.isValid()) {
+        throw new Error('Invalid start date');
+      }
+      if (!to.isValid()) {
+        throw new Error('Invalid end date');
+      }
+      return new DateRange(title, from, to);
+    };
+
+    Config.prototype._locale = function(val) {
+      return $.extend({
+        applyButtonTitle: 'Apply',
+        cancelButtonTitle: 'Cancel',
+        inputFormat: 'L',
+        startLabel: 'Start',
+        endLabel: 'End'
+      }, val || {});
+    };
+
+    Config.prototype._orientation = function(val) {
+      val || (val = 'right');
+      if (val !== 'right' && val !== 'left') {
+        throw new Error('Invalid orientation');
+      }
+      return ko.observable(val);
+    };
+
+    Config.prototype._dateObservable = function(val, mode, minBoundary, maxBoundary) {
+      var computed, fitMax, fitMin, observable;
+      observable = ko.observable();
+      computed = ko.computed({
+        read: function() {
+          return observable();
+        },
+        write: (function(_this) {
+          return function(newValue) {
+            var oldValue;
+            newValue = computed.fit(newValue);
+            oldValue = observable();
+            if (!(oldValue && newValue.isSame(oldValue))) {
+              return observable(newValue);
+            }
+          };
+        })(this)
+      });
+      computed.mode = mode || 'inclusive';
+      fitMin = (function(_this) {
+        return function(val) {
+          var min;
+          if (minBoundary) {
+            min = minBoundary();
+            switch (minBoundary.mode) {
+              case 'extended':
+                min = min.clone().startOf(_this.period());
+                break;
+              case 'exclusive':
+                min = min.clone().endOf(_this.period()).add(1, 'millisecond');
+            }
+            val = moment.max(min, val);
+          }
+          return val;
+        };
+      })(this);
+      fitMax = (function(_this) {
+        return function(val) {
+          var max;
+          if (maxBoundary) {
+            max = maxBoundary();
+            switch (maxBoundary.mode) {
+              case 'extended':
+                max = max.clone().endOf(_this.period());
+                break;
+              case 'exclusive':
+                max = max.clone().startOf(_this.period()).subtract(1, 'millisecond');
+            }
+            val = moment.min(max, val);
+          }
+          return val;
+        };
+      })(this);
+      computed.fit = (function(_this) {
+        return function(val) {
+          val = MomentUtil.tz(val, _this.timeZone());
+          return fitMax(fitMin(val));
+        };
+      })(this);
+      computed(val);
+      computed.clone = (function(_this) {
+        return function() {
+          return _this._dateObservable(observable(), computed.mode, minBoundary, maxBoundary);
+        };
+      })(this);
+      computed.isWithinBoundaries = (function(_this) {
+        return function(date) {
+          var between, max, maxExclusive, min, minExclusive, sameMax, sameMin;
+          date = MomentUtil.tz(date, _this.timeZone());
+          min = minBoundary();
+          max = maxBoundary();
+          between = date.isBetween(min, max, _this.period());
+          sameMin = date.isSame(min, _this.period());
+          sameMax = date.isSame(max, _this.period());
+          minExclusive = minBoundary.mode === 'exclusive';
+          maxExclusive = maxBoundary.mode === 'exclusive';
+          return between || (!minExclusive && sameMin && !(maxExclusive && sameMax)) || (!maxExclusive && sameMax && !(minExclusive && sameMin));
+        };
+      })(this);
+      if (minBoundary) {
+        computed.minBoundary = minBoundary;
+        minBoundary.subscribe(function() {
+          return computed(observable());
+        });
+      }
+      if (maxBoundary) {
+        computed.maxBoundary = maxBoundary;
+        maxBoundary.subscribe(function() {
+          return computed(observable());
+        });
+      }
+      return computed;
+    };
+
+    Config.prototype._defaultRanges = function() {
+      return {
+        'Last 30 days': [moment().subtract(29, 'days'), moment()],
+        'Last 90 days': [moment().subtract(89, 'days'), moment()],
+        'Last Year': [moment().subtract(1, 'year').add(1, 'day'), moment()],
+        'All Time': 'all-time',
+        'Custom Range': 'custom'
+      };
+    };
+
+    Config.prototype._anchorElement = function(val) {
+      return $(val);
+    };
+
+    Config.prototype._parentElement = function(val) {
+      return $(val || (this.standalone() ? this.anchorElement : 'body'));
+    };
+
+    Config.prototype._callback = function(val) {
+      if (val && !$.isFunction(val)) {
+        throw new Error('Invalid callback (not a function)');
+      }
+      return val;
+    };
+
+    return Config;
+
+  })();
+
+  CalendarHeaderView = (function() {
+    function CalendarHeaderView(calendarView) {
+      this.clickNextButton = bind(this.clickNextButton, this);
+      this.clickPrevButton = bind(this.clickPrevButton, this);
+      this.currentDate = calendarView.currentDate;
+      this.period = calendarView.period;
+      this.timeZone = calendarView.timeZone;
+      this.firstDate = calendarView.firstDate;
+      this.firstYearOfDecade = calendarView.firstYearOfDecade;
+      this.prevDate = ko.pureComputed((function(_this) {
+        return function() {
+          var amount, period, ref;
+          ref = _this.period.nextPageArguments(), amount = ref[0], period = ref[1];
+          return _this.currentDate().clone().subtract(amount, period);
+        };
+      })(this));
+      this.nextDate = ko.pureComputed((function(_this) {
+        return function() {
+          var amount, period, ref;
+          ref = _this.period.nextPageArguments(), amount = ref[0], period = ref[1];
+          return _this.currentDate().clone().add(amount, period);
+        };
+      })(this));
+      this.selectedMonth = ko.computed({
+        read: (function(_this) {
+          return function() {
+            return _this.currentDate().month();
+          };
+        })(this),
+        write: (function(_this) {
+          return function(newValue) {
+            var newDate;
+            newDate = _this.currentDate().clone().month(newValue);
+            if (!newDate.isSame(_this.currentDate(), 'month')) {
+              return _this.currentDate(newDate);
+            }
+          };
+        })(this),
+        pure: true
+      });
+      this.selectedYear = ko.computed({
+        read: (function(_this) {
+          return function() {
+            return _this.currentDate().year();
+          };
+        })(this),
+        write: (function(_this) {
+          return function(newValue) {
+            var newDate;
+            newDate = _this.currentDate().clone().year(newValue);
+            if (!newDate.isSame(_this.currentDate(), 'year')) {
+              return _this.currentDate(newDate);
+            }
+          };
+        })(this),
+        pure: true
+      });
+      this.selectedDecade = ko.computed({
+        read: (function(_this) {
+          return function() {
+            return _this.firstYearOfDecade(_this.currentDate()).year();
+          };
+        })(this),
+        write: (function(_this) {
+          return function(newValue) {
+            var newDate, newYear, offset;
+            offset = (_this.currentDate().year() - _this.selectedDecade()) % 9;
+            newYear = newValue + offset;
+            newDate = _this.currentDate().clone().year(newYear);
+            if (!newDate.isSame(_this.currentDate(), 'year')) {
+              return _this.currentDate(newDate);
+            }
+          };
+        })(this),
+        pure: true
+      });
+    }
+
+    CalendarHeaderView.prototype.clickPrevButton = function() {
+      return this.currentDate(this.prevDate());
+    };
+
+    CalendarHeaderView.prototype.clickNextButton = function() {
+      return this.currentDate(this.nextDate());
+    };
+
+    CalendarHeaderView.prototype.prevArrowCss = function() {
+      var date, ref;
+      date = this.firstDate().clone().subtract(1, 'millisecond');
+      if ((ref = this.period()) === 'day' || ref === 'week') {
+        date = date.endOf('month');
+      }
+      return {
+        'arrow-hidden': !this.currentDate.isWithinBoundaries(date)
+      };
+    };
+
+    CalendarHeaderView.prototype.nextArrowCss = function() {
+      var cols, date, ref, ref1, rows;
+      ref = this.period.dimentions(), cols = ref[0], rows = ref[1];
+      date = this.firstDate().clone().add(cols * rows, this.period());
+      if ((ref1 = this.period()) === 'day' || ref1 === 'week') {
+        date = date.startOf('month');
+      }
+      return {
+        'arrow-hidden': !this.currentDate.isWithinBoundaries(date)
+      };
+    };
+
+    CalendarHeaderView.prototype.monthOptions = function() {
+      var j, maxMonth, minMonth, results;
+      minMonth = this.currentDate.minBoundary().isSame(this.currentDate(), 'year') ? this.currentDate.minBoundary().month() : 0;
+      maxMonth = this.currentDate.maxBoundary().isSame(this.currentDate(), 'year') ? this.currentDate.maxBoundary().month() : 11;
+      return (function() {
+        results = [];
+        for (var j = minMonth; minMonth <= maxMonth ? j <= maxMonth : j >= maxMonth; minMonth <= maxMonth ? j++ : j--){ results.push(j); }
+        return results;
+      }).apply(this);
+    };
+
+    CalendarHeaderView.prototype.yearOptions = function() {
+      var j, ref, ref1, results;
+      return (function() {
+        results = [];
+        for (var j = ref = this.currentDate.minBoundary().year(), ref1 = this.currentDate.maxBoundary().year(); ref <= ref1 ? j <= ref1 : j >= ref1; ref <= ref1 ? j++ : j--){ results.push(j); }
+        return results;
+      }).apply(this);
+    };
+
+    CalendarHeaderView.prototype.decadeOptions = function() {
+      return ArrayUtils.uniqArray(this.yearOptions().map((function(_this) {
+        return function(year) {
+          var momentObj;
+          momentObj = MomentUtil.tz([year], _this.timeZone());
+          return _this.firstYearOfDecade(momentObj).year();
+        };
+      })(this)));
+    };
+
+    CalendarHeaderView.prototype.monthSelectorAvailable = function() {
+      var ref;
+      return (ref = this.period()) === 'day' || ref === 'week';
+    };
+
+    CalendarHeaderView.prototype.yearSelectorAvailable = function() {
+      return this.period() !== 'year';
+    };
+
+    CalendarHeaderView.prototype.decadeSelectorAvailable = function() {
+      return this.period() === 'year';
+    };
+
+    CalendarHeaderView.prototype.monthFormatter = function(x) {
+      return moment.utc([2015, x]).format('MMM');
+    };
+
+    CalendarHeaderView.prototype.yearFormatter = function(x) {
+      return moment.utc([x]).format('YYYY');
+    };
+
+    CalendarHeaderView.prototype.decadeFormatter = function(from) {
+      var cols, ref, rows, to;
+      ref = Period.dimentions('year'), cols = ref[0], rows = ref[1];
+      to = from + cols * rows - 1;
+      return from + " – " + to;
+    };
+
+    return CalendarHeaderView;
+
+  })();
+
+  CalendarView = (function() {
+    function CalendarView(mainView, dateSubscribable, type) {
+      this.cssForDate = bind(this.cssForDate, this);
+      this.eventsForDate = bind(this.eventsForDate, this);
+      this.formatDateTemplate = bind(this.formatDateTemplate, this);
+      this.tableValues = bind(this.tableValues, this);
+      this.inRange = bind(this.inRange, this);
+      this.period = mainView.period;
+      this.single = mainView.single;
+      this.timeZone = mainView.timeZone;
+      this.locale = mainView.locale;
+      this.startDate = mainView.startDate;
+      this.endDate = mainView.endDate;
+      this.isCustomPeriodRangeActive = mainView.isCustomPeriodRangeActive;
+      this.type = type;
+      this.label = mainView.locale[type + "Label"] || '';
+      this.hoverDate = ko.observable(null);
+      this.activeDate = dateSubscribable;
+      this.currentDate = dateSubscribable.clone();
+      this.inputDate = ko.computed({
+        read: (function(_this) {
+          return function() {
+            return (_this.hoverDate() || _this.activeDate()).format(_this.locale.inputFormat);
+          };
+        })(this),
+        write: (function(_this) {
+          return function(newValue) {
+            var newDate;
+            newDate = MomentUtil.tz(newValue, _this.locale.inputFormat, _this.timeZone());
+            if (newDate.isValid()) {
+              return _this.activeDate(newDate);
+            }
+          };
+        })(this),
+        pure: true
+      });
+      this.firstDate = ko.pureComputed((function(_this) {
+        return function() {
+          var date, firstDayOfMonth;
+          date = _this.currentDate().clone().startOf(_this.period.scale());
+          switch (_this.period()) {
+            case 'day':
+            case 'week':
+              firstDayOfMonth = date.clone();
+              date.weekday(0);
+              if (date.isAfter(firstDayOfMonth) || date.isSame(firstDayOfMonth, 'day')) {
+                date.subtract(1, 'week');
+              }
+              break;
+            case 'year':
+              date = _this.firstYearOfDecade(date);
+          }
+          return date;
+        };
+      })(this));
+      this.activeDate.subscribe((function(_this) {
+        return function(newValue) {
+          return _this.currentDate(newValue);
+        };
+      })(this));
+      this.headerView = new CalendarHeaderView(this);
+    }
+
+    CalendarView.prototype.calendar = function() {
+      var col, cols, date, iterator, j, ref, ref1, results, row, rows;
+      ref = this.period.dimentions(), cols = ref[0], rows = ref[1];
+      iterator = new MomentIterator(this.firstDate(), this.period());
+      results = [];
+      for (row = j = 1, ref1 = rows; 1 <= ref1 ? j <= ref1 : j >= ref1; row = 1 <= ref1 ? ++j : --j) {
+        results.push((function() {
+          var l, ref2, results1;
+          results1 = [];
+          for (col = l = 1, ref2 = cols; 1 <= ref2 ? l <= ref2 : l >= ref2; col = 1 <= ref2 ? ++l : --l) {
+            date = iterator.next();
+            if (this.type === 'end') {
+              results1.push(date.endOf(this.period()));
+            } else {
+              results1.push(date.startOf(this.period()));
+            }
+          }
+          return results1;
+        }).call(this));
+      }
+      return results;
+    };
+
+    CalendarView.prototype.weekDayNames = function() {
+      return ArrayUtils.rotateArray(moment.weekdaysMin(), moment.localeData().firstDayOfWeek());
+    };
+
+    CalendarView.prototype.inRange = function(date) {
+      return date.isAfter(this.startDate(), this.period()) && date.isBefore(this.endDate(), this.period()) || (date.isSame(this.startDate(), this.period()) || date.isSame(this.endDate(), this.period()));
+    };
+
+    CalendarView.prototype.tableValues = function(date) {
+      var format, months, quarter;
+      format = this.period.format();
+      switch (this.period()) {
+        case 'day':
+        case 'month':
+        case 'year':
+          return [
+            {
+              html: date.format(format)
+            }
+          ];
+        case 'week':
+          date = date.clone().startOf(this.period());
+          return MomentIterator.array(date, 7, 'day').map((function(_this) {
+            return function(date) {
+              return {
+                html: date.format(format),
+                css: {
+                  'week-day': true,
+                  unavailable: _this.cssForDate(date, true).unavailable
+                }
+              };
+            };
+          })(this));
+        case 'quarter':
+          quarter = date.format(format);
+          date = date.clone().startOf('quarter');
+          months = MomentIterator.array(date, 3, 'month').map(function(date) {
+            return date.format('MMM');
+          });
+          return [
+            {
+              html: quarter + "<br><span class='months'>" + (months.join(", ")) + "</span>"
+            }
+          ];
+      }
+    };
+
+    CalendarView.prototype.formatDateTemplate = function(date) {
+      return {
+        nodes: $("<div>" + (this.formatDate(date)) + "</div>").children()
+      };
+    };
+
+    CalendarView.prototype.eventsForDate = function(date) {
+      return {
+        click: (function(_this) {
+          return function() {
+            if (_this.activeDate.isWithinBoundaries(date)) {
+              return _this.activeDate(date);
+            }
+          };
+        })(this),
+        mouseenter: (function(_this) {
+          return function() {
+            if (_this.activeDate.isWithinBoundaries(date)) {
+              return _this.hoverDate(_this.activeDate.fit(date));
+            }
+          };
+        })(this),
+        mouseleave: (function(_this) {
+          return function() {
+            return _this.hoverDate(null);
+          };
+        })(this)
+      };
+    };
+
+    CalendarView.prototype.cssForDate = function(date, periodIsDay) {
+      var differentMonth, inRange, obj1, onRangeEnd, withinBoundaries;
+      onRangeEnd = date.isSame(this.activeDate(), this.period());
+      withinBoundaries = this.activeDate.isWithinBoundaries(date);
+      periodIsDay || (periodIsDay = this.period() === 'day');
+      differentMonth = !date.isSame(this.currentDate(), 'month');
+      inRange = this.inRange(date);
+      return (
+        obj1 = {
+          "in-range": !this.single() && (inRange || onRangeEnd)
+        },
+        obj1[this.type + "-date"] = onRangeEnd,
+        obj1["clickable"] = withinBoundaries && !this.isCustomPeriodRangeActive(),
+        obj1["out-of-boundaries"] = !withinBoundaries || this.isCustomPeriodRangeActive(),
+        obj1["unavailable"] = periodIsDay && differentMonth,
+        obj1
+      );
+    };
+
+    CalendarView.prototype.firstYearOfDecade = function(date) {
+      var currentYear, firstYear, offset, year;
+      currentYear = MomentUtil.tz(moment(), this.timeZone()).year();
+      firstYear = currentYear - 4;
+      offset = Math.floor((date.year() - firstYear) / 9);
+      year = firstYear + offset * 9;
+      return MomentUtil.tz([year], this.timeZone());
+    };
+
+    return CalendarView;
+
+  })();
+
+  DateRangePickerView = (function() {
+    function DateRangePickerView(options) {
+      var endDate, ref, startDate, wrapper;
+      if (options == null) {
+        options = {};
+      }
+      this.outsideClick = bind(this.outsideClick, this);
+      this.setCustomPeriodRange = bind(this.setCustomPeriodRange, this);
+      this.setDateRange = bind(this.setDateRange, this);
+      new Config(options).extend(this);
+      this.startCalendar = new CalendarView(this, this.startDate, 'start');
+      this.endCalendar = new CalendarView(this, this.endDate, 'end');
+      this.startDateInput = this.startCalendar.inputDate;
+      this.endDateInput = this.endCalendar.inputDate;
+      this.dateRange = ko.observable([this.startDate(), this.endDate()]);
+      this.startDate.subscribe((function(_this) {
+        return function(newValue) {
+          if (_this.single()) {
+            _this.endDate(newValue.clone().endOf(_this.period()));
+            _this.updateDateRange();
+            return _this.close();
+          } else {
+            if (_this.endDate().isSame(newValue)) {
+              _this.endDate(_this.endDate().clone().endOf(_this.period()));
+            }
+            if (_this.standalone()) {
+              return _this.updateDateRange();
+            }
+          }
+        };
+      })(this));
+      this.style = ko.observable({});
+      if (this.callback) {
+        this.dateRange.subscribe((function(_this) {
+          return function(newValue) {
+            var endDate, startDate;
+            startDate = newValue[0], endDate = newValue[1];
+            return _this.callback(startDate.clone(), endDate.clone(), _this.period());
+          };
+        })(this));
+        if (this.forceUpdate) {
+          ref = this.dateRange(), startDate = ref[0], endDate = ref[1];
+          this.callback(startDate.clone(), endDate.clone(), this.period());
+        }
+      }
+      if (this.anchorElement) {
+        wrapper = $("<div data-bind=\"stopBinding: true\"></div>").appendTo(this.parentElement);
+        this.containerElement = $(this.constructor.template).appendTo(wrapper);
+        ko.applyBindings(this, this.containerElement.get(0));
+        this.anchorElement.click((function(_this) {
+          return function() {
+            _this.updatePosition();
+            return _this.toggle();
+          };
+        })(this));
+        if (!this.standalone()) {
+          $(document).on('mousedown.daterangepicker', this.outsideClick).on('touchend.daterangepicker', this.outsideClick).on('click.daterangepicker', '[data-toggle=dropdown]', this.outsideClick).on('focusin.daterangepicker', this.outsideClick);
+        }
+      }
+      if (this.opened()) {
+        this.updatePosition();
+      }
+    }
+
+    DateRangePickerView.prototype.periodProxy = Period;
+
+    DateRangePickerView.prototype.calendars = function() {
+      if (this.single()) {
+        return [this.startCalendar];
+      } else {
+        return [this.startCalendar, this.endCalendar];
+      }
+    };
+
+    DateRangePickerView.prototype.updateDateRange = function() {
+      return this.dateRange([this.startDate(), this.endDate()]);
+    };
+
+    DateRangePickerView.prototype.cssClasses = function() {
+      var j, len, obj, period, ref;
+      obj = {
+        single: this.single(),
+        opened: this.standalone() || this.opened(),
+        expanded: this.standalone() || this.single() || this.expanded(),
+        standalone: this.standalone(),
+        'hide-weekdays': this.hideWeekdays(),
+        'hide-periods': (this.periods().length + this.customPeriodRanges.length) === 1,
+        'orientation-left': this.orientation() === 'left',
+        'orientation-right': this.orientation() === 'right'
+      };
+      ref = Period.allPeriods;
+      for (j = 0, len = ref.length; j < len; j++) {
+        period = ref[j];
+        obj[period + "-period"] = period === this.period();
+      }
+      return obj;
+    };
+
+    DateRangePickerView.prototype.isActivePeriod = function(period) {
+      return this.period() === period;
+    };
+
+    DateRangePickerView.prototype.isActiveDateRange = function(dateRange) {
+      var dr, j, len, ref;
+      if (dateRange.constructor === CustomDateRange) {
+        ref = this.ranges;
+        for (j = 0, len = ref.length; j < len; j++) {
+          dr = ref[j];
+          if (dr.constructor !== CustomDateRange && this.isActiveDateRange(dr)) {
+            return false;
+          }
+        }
+        return true;
+      } else {
+        return this.startDate().isSame(dateRange.startDate, 'day') && this.endDate().isSame(dateRange.endDate, 'day');
+      }
+    };
+
+    DateRangePickerView.prototype.isActiveCustomPeriodRange = function(customPeriodRange) {
+      return this.isActiveDateRange(customPeriodRange) && this.isCustomPeriodRangeActive();
+    };
+
+    DateRangePickerView.prototype.inputFocus = function() {
+      return this.expanded(true);
+    };
+
+    DateRangePickerView.prototype.setPeriod = function(period) {
+      this.isCustomPeriodRangeActive(false);
+      this.period(period);
+      return this.expanded(true);
+    };
+
+    DateRangePickerView.prototype.setDateRange = function(dateRange) {
+      if (dateRange.constructor === CustomDateRange) {
+        return this.expanded(true);
+      } else {
+        this.expanded(false);
+        this.close();
+        this.period('day');
+        this.startDate(dateRange.startDate);
+        this.endDate(dateRange.endDate);
+        return this.updateDateRange();
+      }
+    };
+
+    DateRangePickerView.prototype.setCustomPeriodRange = function(customPeriodRange) {
+      this.isCustomPeriodRangeActive(true);
+      return this.setDateRange(customPeriodRange);
+    };
+
+    DateRangePickerView.prototype.applyChanges = function() {
+      this.close();
+      return this.updateDateRange();
+    };
+
+    DateRangePickerView.prototype.cancelChanges = function() {
+      return this.close();
+    };
+
+    DateRangePickerView.prototype.open = function() {
+      return this.opened(true);
+    };
+
+    DateRangePickerView.prototype.close = function() {
+      if (!this.standalone()) {
+        return this.opened(false);
+      }
+    };
+
+    DateRangePickerView.prototype.toggle = function() {
+      if (this.opened()) {
+        return this.close();
+      } else {
+        return this.open();
+      }
+    };
+
+    DateRangePickerView.prototype.updatePosition = function() {
+      var parentOffset, parentRightEdge, style;
+      if (this.standalone()) {
+        return;
+      }
+      parentOffset = {
+        top: 0,
+        left: 0
+      };
+      parentRightEdge = $(window).width();
+      if (!this.parentElement.is('body')) {
+        parentOffset = {
+          top: this.parentElement.offset().top - this.parentElement.scrollTop(),
+          left: this.parentElement.offset().left - this.parentElement.scrollLeft()
+        };
+        parentRightEdge = this.parentElement.get(0).clientWidth + this.parentElement.offset().left;
+      }
+      style = {
+        top: (this.anchorElement.offset().top + this.anchorElement.outerHeight() - parentOffset.top) + 'px',
+        left: 'auto',
+        right: 'auto'
+      };
+      switch (this.orientation()) {
+        case 'left':
+          if (this.containerElement.offset().left < 0) {
+            style.left = '9px';
+          } else {
+            style.right = (parentRightEdge - (this.anchorElement.offset().left) - this.anchorElement.outerWidth()) + 'px';
+          }
+          break;
+        default:
+          if (this.containerElement.offset().left + this.containerElement.outerWidth() > $(window).width()) {
+            style.right = '0';
+          } else {
+            style.left = (this.anchorElement.offset().left - parentOffset.left) + 'px';
+          }
+      }
+      return this.style(style);
+    };
+
+    DateRangePickerView.prototype.outsideClick = function(event) {
+      var target;
+      target = $(event.target);
+      if (!(event.type === 'focusin' || target.closest(this.anchorElement).length || target.closest(this.containerElement).length || target.closest('.calendar').length)) {
+        return this.close();
+      }
+    };
+
+    return DateRangePickerView;
+
+  })();
+
+  DateRangePickerView.template = '<div class="daterangepicker" data-bind="css: $data.cssClasses(), style: $data.style()"> <div class="controls"> <ul class="periods"> <!-- ko foreach: $data.periods --> <li class="period" data-bind="css: {active: $parent.isActivePeriod($data) && !$parent.isCustomPeriodRangeActive()}, text: $parent.periodProxy.title($data), click: function(){ $parent.setPeriod($data); }"></li> <!-- /ko --> <!-- ko foreach: $data.customPeriodRanges --> <li class="period" data-bind="css: {active: $parent.isActiveCustomPeriodRange($data)}, text: $data.title, click: function(){ $parent.setCustomPeriodRange($data); }"></li> <!-- /ko --> </ul> <ul class="ranges" data-bind="foreach: $data.ranges"> <li class="range" data-bind="css: {active: $parent.isActiveDateRange($data)}, text: $data.title, click: function(){ $parent.setDateRange($data); }"></li> </ul> <form data-bind="submit: $data.applyChanges"> <div class="custom-range-inputs"> <input type="text" data-bind="value: $data.startDateInput, event: {focus: $data.inputFocus}" /> <input type="text" data-bind="value: $data.endDateInput, event: {focus: $data.inputFocus}" /> </div> <div class="custom-range-buttons"> <button class="apply-btn" type="submit" data-bind="text: $data.locale.applyButtonTitle, click: $data.applyChanges"></button> <button class="cancel-btn" data-bind="text: $data.locale.cancelButtonTitle, click: $data.cancelChanges"></button> </div> </form> </div> <!-- ko foreach: $data.calendars() --> <div class="calendar"> <div class="calendar-title" data-bind="text: $data.label"></div> <div class="calendar-header" data-bind="with: $data.headerView"> <div class="arrow" data-bind="css: $data.prevArrowCss()"> <button data-bind="click: $data.clickPrevButton"><span class="arrow-left"></span></button> </div> <div class="calendar-selects"> <select class="month-select" data-bind="options: $data.monthOptions(), optionsText: $data.monthFormatter, valueAllowUnset: true, value: $data.selectedMonth, css: {hidden: !$data.monthSelectorAvailable()}"></select> <select class="year-select" data-bind="options: $data.yearOptions(), optionsText: $data.yearFormatter, valueAllowUnset: true, value: $data.selectedYear, css: {hidden: !$data.yearSelectorAvailable()}"></select> <select class="decade-select" data-bind="options: $data.decadeOptions(), optionsText: $data.decadeFormatter, valueAllowUnset: true, value: $data.selectedDecade, css: {hidden: !$data.decadeSelectorAvailable()}"></select> </div> <div class="arrow" data-bind="css: $data.nextArrowCss()"> <button data-bind="click: $data.clickNextButton"><span class="arrow-right"></span></button> </div> </div> <div class="calendar-table"> <!-- ko if: $parent.periodProxy.showWeekDayNames($data.period()) --> <div class="table-row weekdays" data-bind="foreach: $data.weekDayNames()"> <div class="table-col"> <div class="table-value-wrapper"> <div class="table-value" data-bind="text: $data"></div> </div> </div> </div> <!-- /ko --> <!-- ko foreach: $data.calendar() --> <div class="table-row" data-bind="foreach: $data"> <div class="table-col" data-bind="event: $parents[1].eventsForDate($data), css: $parents[1].cssForDate($data)"> <div class="table-value-wrapper" data-bind="foreach: $parents[1].tableValues($data)"> <div class="table-value" data-bind="html: $data.html, css: $data.css"></div> </div> </div> </div> <!-- /ko --> </div> </div> <!-- /ko --> </div>';
+
+  $.extend($.fn.daterangepicker, {
+    ArrayUtils: ArrayUtils,
+    MomentIterator: MomentIterator,
+    MomentUtil: MomentUtil,
+    Period: Period,
+    Config: Config,
+    DateRange: DateRange,
+    AllTimeDateRange: AllTimeDateRange,
+    CustomDateRange: CustomDateRange,
+    DateRangePickerView: DateRangePickerView,
+    CalendarView: CalendarView,
+    CalendarHeaderView: CalendarHeaderView
+  });
+
+}).call(this);
diff --git a/public/packages/sorttable/sorttable.js b/public/packages/sorttable/sorttable.js
new file mode 100644 (file)
index 0000000..acc2ac0
--- /dev/null
@@ -0,0 +1,519 @@
+/*== Original version with customisations by Cubedesigners ==*/
+/**
+ * - CSS-based sort arrows
+ * - Custom sort value specified using data-sort-value attribute on the cell <td>
+ * - Allow override of default sort direction using data-sort-direction attribute on header <th>
+ * - Default to descending order for numeric values, ascending for others
+*/
+/*
+  SortTable
+  version 2
+  7th April 2007
+  Stuart Langridge, http://www.kryogenix.org/code/browser/sorttable/
+
+  Instructions:
+  Download this file
+  Add <script src="sorttable.js"></script> to your HTML
+  Add class="sortable" to any table you'd like to make sortable
+  Click on the headers to sort
+
+  Thanks to many, many people for contributions and suggestions.
+  Licenced as X11: http://www.kryogenix.org/code/browser/licence.html
+  This basically means: do what you want with it.
+*/
+
+/* jshint -W051, -W083, -W027 */
+
+var stIsIE = /*@cc_on!@*/false;
+
+sorttable = {
+    init: function() {
+        // quit if this function has already been called
+        if (arguments.callee.done) return;
+        // flag this function so we don't do the same thing twice
+        arguments.callee.done = true;
+        // kill the timer
+        if (_timer) clearInterval(_timer);
+
+        if (!document.createElement || !document.getElementsByTagName) return;
+
+        sorttable.DATE_RE = /^(\d\d?)[\/\.-](\d\d?)[\/\.-]((\d\d)?\d\d)$/;
+
+        forEach(document.getElementsByTagName('table'), function(table) {
+            if (table.className.search(/\bsortable\b/) != -1) {
+                sorttable.makeSortable(table);
+            }
+        });
+
+    },
+
+    makeSortable: function(table) {
+        if (table.getElementsByTagName('thead').length === 0) {
+            // table doesn't have a tHead. Since it should have, create one and
+            // put the first table row in it.
+            the = document.createElement('thead');
+            the.appendChild(table.rows[0]);
+            table.insertBefore(the,table.firstChild);
+        }
+        // Safari doesn't support table.tHead, sigh
+        if (table.tHead === null) table.tHead = table.getElementsByTagName('thead')[0];
+
+        if (table.tHead.rows.length != 1) return; // can't cope with two header rows
+
+        // Sorttable v1 put rows with a class of "sortbottom" at the bottom (as
+        // "total" rows, for example). This is B&R, since what you're supposed
+        // to do is put them in a tfoot. So, if there are sortbottom rows,
+        // for backwards compatibility, move them to tfoot (creating it if needed).
+        sortbottomrows = [];
+        for (var i=0; i<table.rows.length; i++) {
+            if (table.rows[i].className.search(/\bsortbottom\b/) != -1) {
+                sortbottomrows[sortbottomrows.length] = table.rows[i];
+            }
+        }
+        if (sortbottomrows) {
+            if (table.tFoot === null) {
+                // table doesn't have a tfoot. Create one.
+                tfo = document.createElement('tfoot');
+                table.appendChild(tfo);
+            }
+            for (i=0; i<sortbottomrows.length; i++) {
+                tfo.appendChild(sortbottomrows[i]);
+            }
+            //delete sortbottomrows;
+        }
+
+        // work through each column and calculate its type
+        headrow = table.tHead.rows[0].cells;
+        for (i=0; i<headrow.length; i++) {
+            // manually override the type with a sorttable_type attribute
+            if (!headrow[i].className.match(/\bsorttable_nosort\b/)) { // skip this col
+                mtch = headrow[i].className.match(/\bsorttable_([a-z0-9]+)\b/);
+                if (mtch) { override = mtch[1]; }
+                if (mtch && typeof sorttable['sort_' + override] == 'function') {
+                    headrow[i].sorttable_sortfunction = 'sort_' + override;
+                } else {
+                    headrow[i].sorttable_sortfunction = sorttable.guessType(table,i);
+                }
+                // make it clickable to sort
+                headrow[i].sorttable_columnindex = i;
+                headrow[i].sorttable_tbody = table.tBodies[0];
+                dean_addEvent(headrow[i],"click", sorttable.innerSortFunction = function(e) {
+
+                    if (this.className.search(/\bsorttable_sorted_ascending\b/) != -1) {
+                        // if we're already sorted by this column, just
+                        // reverse the table, which is quicker
+                        sorttable.reverse(this.sorttable_tbody);
+                        this.className = this.className.replace('sorttable_sorted_ascending',
+                            'sorttable_sorted_descending');
+                        // this.removeChild(document.getElementById('sorttable_sortfwdind'));
+                        // sortrevind = document.createElement('span');
+                        // sortrevind.id = "sorttable_sortrevind";
+                        // sortrevind.innerHTML = stIsIE ? '&nbsp<font face="webdings">5</font>' : '&nbsp;&#x25B4;';
+                        // this.appendChild(sortrevind);
+                        return;
+                    }
+                    if (this.className.search(/\bsorttable_sorted_descending\b/) != -1) {
+                        // if we're already sorted by this column in reverse, just
+                        // re-reverse the table, which is quicker
+                        sorttable.reverse(this.sorttable_tbody);
+                        this.className = this.className.replace('sorttable_sorted_descending',
+                            'sorttable_sorted_ascending');
+                        // this.removeChild(document.getElementById('sorttable_sortrevind'));
+                        // sortfwdind = document.createElement('span');
+                        // sortfwdind.id = "sorttable_sortfwdind";
+                        // sortfwdind.innerHTML = stIsIE ? '&nbsp<font face="webdings">6</font>' : '&nbsp;&#x25BE;';
+                        // this.appendChild(sortfwdind);
+                        return;
+                    }
+
+                    // remove sorttable_sorted classes
+                    theadrow = this.parentNode;
+                    forEach(theadrow.childNodes, function(cell) {
+                        if (cell.nodeType == 1) { // an element
+                            cell.className = cell.className.replace('sorttable_sorted_ascending','');
+                            cell.className = cell.className.replace('sorttable_sorted_descending','');
+                        }
+                    });
+                    // sortfwdind = document.getElementById('sorttable_sortfwdind');
+                    // if (sortfwdind) { sortfwdind.parentNode.removeChild(sortfwdind); }
+                    // sortrevind = document.getElementById('sorttable_sortrevind');
+                    // if (sortrevind) { sortrevind.parentNode.removeChild(sortrevind); }
+
+                    // sortfwdind = document.createElement('span');
+                    // sortfwdind.id = "sorttable_sortfwdind";
+                    // sortfwdind.innerHTML = stIsIE ? '&nbsp<font face="webdings">6</font>' : '&nbsp;&#x25BE;';
+                    // this.appendChild(sortfwdind);
+
+                    // build an array to sort. This is a Schwartzian transform thing,
+                    // i.e., we "decorate" each row with the actual sort key,
+                    // sort based on the sort keys, and then put the rows back in order
+                    // which is a lot faster because you only do getInnerText once per row
+                    row_array = [];
+                    col = this.sorttable_columnindex;
+                    rows = this.sorttable_tbody.rows;
+                    for (var j=0; j<rows.length; j++) {
+                        row_array[row_array.length] = [sorttable.getInnerText(rows[j].cells[col]), rows[j]];
+                    }
+                    /* If you want a stable sort, uncomment the following line */
+                    sorttable.shaker_sort(row_array, sorttable[this.sorttable_sortfunction]);
+                    /* and comment out this one */
+                    //row_array.sort(this.sorttable_sortfunction);
+
+                    // Default sort direction for a column can be overridden using the data-sort-direction attribute
+                    // If it's not defined, then we decide based on the type of data: numeric data is descending by
+                    // default, while others are sorted in ascending order
+                    var sort_direction = this.hasAttribute('data-sort-direction')
+                        ? this.getAttribute('data-sort-direction') : (this.sorttable_sortfunction === 'sort_numeric') ? 'descending' : 'ascending';
+
+                    if (sort_direction === 'descending') {
+                        row_array.reverse(); // Default to descending order sort
+                        this.className += ' sorttable_sorted_descending';
+                    } else {
+                        this.className += ' sorttable_sorted_ascending';
+                    }
+
+                    tb = this.sorttable_tbody;
+                    for (j=0; j<row_array.length; j++) {
+                        tb.appendChild(row_array[j][1]);
+                    }
+
+                    //delete row_array;
+                });
+            }
+        }
+    },
+
+    guessType: function(table, column) {
+        // guess the type of a column based on its first non-blank row
+        sortfn = 'sort_alpha';
+        for (var i=0; i<table.tBodies[0].rows.length; i++) {
+            text = sorttable.getInnerText(table.tBodies[0].rows[i].cells[column]);
+            if (text !== '') {
+                if (text.match(/^-?[£$¤]?[\d,.]+%?$/)) {
+                    return 'sort_numeric';
+                }
+                // check for a date: dd/mm/yyyy or dd/mm/yy
+                // can have / or . or - as separator
+                // can be mm/dd as well
+                possdate = text.match(sorttable.DATE_RE);
+                if (possdate) {
+                    // looks like a date
+                    first = parseInt(possdate[1], 10);
+                    second = parseInt(possdate[2], 10);
+                    if (first > 12) {
+                        // definitely dd/mm
+                        return 'sort_ddmm';
+                    } else if (second > 12) {
+                        return 'sort_mmdd';
+                    } else {
+                        // looks like a date, but we can't tell which, so assume
+                        // that it's dd/mm (English imperialism!) and keep looking
+                        sortfn = 'sort_ddmm';
+                    }
+                }
+            }
+        }
+        return sortfn;
+    },
+
+    getInnerText: function(node) {
+        // gets the text we want to use for sorting for a cell.
+        // strips leading and trailing whitespace.
+        // this is *not* a generic getInnerText function; it's special to sorttable.
+        // for example, you can override the cell text with a data-sort-value attribute.
+        // it also gets .value for <input> fields.
+
+        if (!node) return "";
+
+        hasInputs = (typeof node.getElementsByTagName == 'function') &&
+            node.getElementsByTagName('input').length;
+
+        if (node.nodeType == 1 && node.getAttribute('data-sort-value') !== null) {
+            return node.getAttribute('data-sort-value');
+        }
+        else if (typeof node.textContent != 'undefined' && !hasInputs) {
+            return node.textContent.replace(/^\s+|\s+$/g, '');
+        }
+        else if (typeof node.innerText != 'undefined' && !hasInputs) {
+            return node.innerText.replace(/^\s+|\s+$/g, '');
+        }
+        else if (typeof node.text != 'undefined' && !hasInputs) {
+            return node.text.replace(/^\s+|\s+$/g, '');
+        }
+        else {
+            switch (node.nodeType) {
+                case 3:
+                    if (node.nodeName.toLowerCase() == 'input') {
+                        return node.value.replace(/^\s+|\s+$/g, '');
+                    }
+                    break;
+                case 4:
+                    return node.nodeValue.replace(/^\s+|\s+$/g, '');
+                    break;
+                case 1:
+                case 11:
+                    var innerText = '';
+                    for (var i = 0; i < node.childNodes.length; i++) {
+                        innerText += sorttable.getInnerText(node.childNodes[i]);
+                    }
+                    return innerText.replace(/^\s+|\s+$/g, '');
+                    break;
+                default:
+                    return '';
+            }
+        }
+    },
+
+    reverse: function(tbody) {
+        // reverse the rows in a tbody
+        newrows = [];
+        for (var i=0; i<tbody.rows.length; i++) {
+            newrows[newrows.length] = tbody.rows[i];
+        }
+        for (i=newrows.length-1; i>=0; i--) {
+            tbody.appendChild(newrows[i]);
+        }
+        //delete newrows;
+    },
+
+    /* sort functions
+       each sort function takes two parameters, a and b
+       you are comparing a[0] and b[0] */
+    sort_numeric: function(a,b) {
+        aa = parseFloat(a[0].replace(/[^0-9.-]/g,''));
+        if (isNaN(aa)) aa = 0;
+        bb = parseFloat(b[0].replace(/[^0-9.-]/g,''));
+        if (isNaN(bb)) bb = 0;
+        return aa-bb;
+    },
+    sort_alpha: function(a,b) {
+        return a[0].localeCompare(b[0]);
+        /*
+        if (a[0]==b[0]) return 0;
+        if (a[0]<b[0]) return -1;
+        return 1;
+        */
+    },
+    sort_ddmm: function(a,b) {
+        mtch = a[0].match(sorttable.DATE_RE);
+        y = mtch[3]; m = mtch[2]; d = mtch[1];
+        if (m.length == 1) m = '0'+m;
+        if (d.length == 1) d = '0'+d;
+        dt1 = y+m+d;
+        mtch = b[0].match(sorttable.DATE_RE);
+        y = mtch[3]; m = mtch[2]; d = mtch[1];
+        if (m.length == 1) m = '0'+m;
+        if (d.length == 1) d = '0'+d;
+        dt2 = y+m+d;
+        if (dt1==dt2) return 0;
+        if (dt1<dt2) return -1;
+        return 1;
+    },
+    sort_mmdd: function(a,b) {
+        mtch = a[0].match(sorttable.DATE_RE);
+        y = mtch[3]; d = mtch[2]; m = mtch[1];
+        if (m.length == 1) m = '0'+m;
+        if (d.length == 1) d = '0'+d;
+        dt1 = y+m+d;
+        mtch = b[0].match(sorttable.DATE_RE);
+        y = mtch[3]; d = mtch[2]; m = mtch[1];
+        if (m.length == 1) m = '0'+m;
+        if (d.length == 1) d = '0'+d;
+        dt2 = y+m+d;
+        if (dt1==dt2) return 0;
+        if (dt1<dt2) return -1;
+        return 1;
+    },
+
+    shaker_sort: function(list, comp_func) {
+        // A stable sort function to allow multi-level sorting of data
+        // see: http://en.wikipedia.org/wiki/Cocktail_sort
+        // thanks to Joseph Nahmias
+        var b = 0;
+        var t = list.length - 1;
+        var swap = true;
+        var q;
+
+        while(swap) {
+            swap = false;
+            for(var i = b; i < t; ++i) {
+                if ( comp_func(list[i], list[i+1]) > 0 ) {
+                    q = list[i]; list[i] = list[i+1]; list[i+1] = q;
+                    swap = true;
+                }
+            } // for
+            t--;
+
+            if (!swap) break;
+
+            for(i = t; i > b; --i) {
+                if ( comp_func(list[i], list[i-1]) < 0 ) {
+                    q = list[i]; list[i] = list[i-1]; list[i-1] = q;
+                    swap = true;
+                }
+            } // for
+            b++;
+
+        } // while(swap)
+    }
+};
+
+/* ******************************************************************
+   Supporting functions: bundled here to avoid depending on a library
+   ****************************************************************** */
+
+// Dean Edwards/Matthias Miller/John Resig
+
+/* for Mozilla/Opera9 */
+if (document.addEventListener) {
+    document.addEventListener("DOMContentLoaded", sorttable.init, false);
+}
+
+/* for Internet Explorer */
+/*@cc_on @*/
+/*@if (@_win32)
+    document.write("<script id=__ie_onload defer src=javascript:void(0)><\/script>");
+    var script = document.getElementById("__ie_onload");
+    script.onreadystatechange = function() {
+        if (this.readyState == "complete") {
+            sorttable.init(); // call the onload handler
+        }
+    };
+/*@end @*/
+
+/* for Safari */
+if (/WebKit/i.test(navigator.userAgent)) { // sniff
+    var _timer = setInterval(function() {
+        if (/loaded|complete/.test(document.readyState)) {
+            sorttable.init(); // call the onload handler
+        }
+    }, 10);
+}
+
+/* for other browsers */
+window.onload = sorttable.init;
+
+// written by Dean Edwards, 2005
+// with input from Tino Zijdel, Matthias Miller, Diego Perini
+
+// http://dean.edwards.name/weblog/2005/10/add-event/
+
+function dean_addEvent(element, type, handler) {
+    if (element.addEventListener) {
+        element.addEventListener(type, handler, false);
+    } else {
+        // assign each event handler a unique ID
+        if (!handler.$$guid) handler.$$guid = dean_addEvent.guid++;
+        // create a hash table of event types for the element
+        if (!element.events) element.events = {};
+        // create a hash table of event handlers for each element/event pair
+        var handlers = element.events[type];
+        if (!handlers) {
+            handlers = element.events[type] = {};
+            // store the existing event handler (if there is one)
+            if (element["on" + type]) {
+                handlers[0] = element["on" + type];
+            }
+        }
+        // store the event handler in the hash table
+        handlers[handler.$$guid] = handler;
+        // assign a global event handler to do all the work
+        element["on" + type] = handleEvent;
+    }
+}
+// a counter used to create unique IDs
+dean_addEvent.guid = 1;
+
+function removeEvent(element, type, handler) {
+    if (element.removeEventListener) {
+        element.removeEventListener(type, handler, false);
+    } else {
+        // delete the event handler from the hash table
+        if (element.events && element.events[type]) {
+            delete element.events[type][handler.$$guid];
+        }
+    }
+}
+
+function handleEvent(event) {
+    var returnValue = true;
+    // grab the event object (IE uses a global event object)
+    event = event || fixEvent(((this.ownerDocument || this.document || this).parentWindow || window).event);
+    // get a reference to the hash table of event handlers
+    var handlers = this.events[event.type];
+    // execute each event handler
+    for (var i in handlers) {
+        this.$$handleEvent = handlers[i];
+        if (this.$$handleEvent(event) === false) {
+            returnValue = false;
+        }
+    }
+    return returnValue;
+}
+
+function fixEvent(event) {
+    // add W3C standard event methods
+    event.preventDefault = fixEvent.preventDefault;
+    event.stopPropagation = fixEvent.stopPropagation;
+    return event;
+}
+fixEvent.preventDefault = function() {
+    this.returnValue = false;
+};
+fixEvent.stopPropagation = function() {
+    this.cancelBubble = true;
+};
+
+// Dean's forEach: http://dean.edwards.name/base/forEach.js
+/*
+       forEach, version 1.0
+       Copyright 2006, Dean Edwards
+       License: http://www.opensource.org/licenses/mit-license.php
+*/
+
+// array-like enumeration
+if (!Array.forEach) { // mozilla already supports this
+    Array.forEach = function(array, block, context) {
+        for (var i = 0; i < array.length; i++) {
+            block.call(context, array[i], i, array);
+        }
+    };
+}
+
+// generic enumeration
+Function.prototype.forEach = function(object, block, context) {
+    for (var key in object) {
+        if (typeof this.prototype[key] == "undefined") {
+            block.call(context, object[key], key, object);
+        }
+    }
+};
+
+// character enumeration
+String.forEach = function(string, block, context) {
+    Array.forEach(string.split(""), function(chr, index) {
+        block.call(context, chr, index, string);
+    });
+};
+
+// globally resolve forEach enumeration
+var forEach = function(object, block, context) {
+    if (object) {
+        var resolve = Object; // default
+        if (object instanceof Function) {
+            // functions have a "length" property
+            resolve = Function;
+        } else if (object.forEach instanceof Function) {
+            // the object implements a custom forEach method so use that
+            object.forEach(block, context);
+            return;
+        } else if (typeof object == "string") {
+            // the object is a string
+            resolve = String;
+        } else if (typeof object.length == "number") {
+            // the object is array-like
+            resolve = Array;
+        }
+        resolve.forEach(object, block, context);
+    }
+};
index c296de81840e44f2e85ac8185ff34c1c28ab8954..175be7442a9072a530d6b3d7401bf4548e224e02 100644 (file)
@@ -57,7 +57,7 @@
                 <span>API Method</span>
                 <input class="w-60" type="search" x-model="method" @focus="$el.select()" list="API-methods" autofocus>
                 <datalist id="API-methods">
-                    {{-- List of suggestions for the input... --}}
+                    {{-- List of suggestions for the method input field... --}}
                     <option value="VisitsSummary.get">
                     <option value="VisitsSummary.getVisits">
                     <option value="VisitsSummary.getUniqueVisitors">
                   filteredData = { [$this.date]: filteredData }
               }
 
-              let requestDetails = `<h2 class="mb-4 font-medium text-sm">
+              let requestDetails = `<h2 class="mb-4 font-medium text-sm flex">
                 <span @click="expanded = ! expanded" class="cursor-pointer">
                     <span class="inline-block transition" :class="expanded || '-rotate-90 opacity-50'">▼</span>
                     ${Object.values(filteredData).length} results for <span class="text-blue-600">${$this.method}</span>
                     period: <span class="text-blue-600">${$this.period}</span>)
                 </span>
                 <a class="inline-block px-2 py-0.5 bg-blue-500 text-white text-xs ml-2 rounded" href="${$this.generatedURL}" target="_blank">RAW ↗️</a>
+                <button class="leading-none ml-2 text-red-500 text-xl" @click="$event.target.parentElement.parentElement.remove()" title="Remove">&times;</button>
               </h2>`;
 
               let result = '';
       },
 
       tableFromData(filteredData, hideDateColumn) {
-          let tableClasses = 'border-collapse border border-slate-400 p-2 text-xs';
+          let tableClasses = 'border-collapse border border-slate-500 p-2 text-xs';
           let table = `<table class="${tableClasses}">`;
           let firstRow = Object.values(filteredData)[0];
 
               // console.log('datedSet...', datedSet);
 
               let date = datedSet[0];
-              let dateData = (typeof datedSet[1] === 'object') ? Object.values(datedSet[1]) : datedSet[1];
+              let dateData = datedSet[1];
 
-              table += `<tr class="${ index % 2 === 1 ? 'bg-gray-200' : '' }">`;
+              table += `<tr class="transition hover:bg-yellow-100 ${ index % 2 === 1 ? 'bg-gray-200' : '' }">`;
 
               if (!hideDateColumn) {
                   table += `<td class="${tableClasses}">${date}</td>`;
               }
 
               if (typeof dateData === 'object') {
-                  table += dateData.map(value => `<td class="${tableClasses}">${value}</td>`).join("");
+                  // The table headings are based on the object keys from the first row but the API doesn't always
+                  // return the same set of data. As such, we need to check that the key exists for each row...
+                  table += Object.keys(firstRow).map(heading_key =>
+                      `<td class="${tableClasses}">${ typeof dateData[heading_key] === 'undefined' ? '–' : dateData[heading_key] }</td>`
+                  ).join("");
+                  //table += dateData.map(value => `<td class="${tableClasses}">${value}</td>`).join("");
               } else {
                   table += `<td class="${tableClasses}">${dateData}</td>`;
               }
index e3db6b933d00fd5e46afe6640f9c7fef0bd7ce7e..d48f682bb335cd12df68dd5804e5962e2b710632 100644 (file)
 @extends(backpack_view('blank'))
 
 @section('after_styles')
+    <link rel="stylesheet" href="{{ asset('packages/daterangepicker/daterangepicker.css') }}">
     <style>
         .summary {
-            display: flex;
-            flex-wrap: wrap;
-            align-items: center;
+            display: grid;
+            grid-template-columns: max-content 1fr;
         }
         .summary dt, .summary dd {
-            flex: 1 1 50%;
             margin: 0;
             padding: 0.5em;
         }
-        .summary dt:nth-of-type(odd), .summary dd:nth-of-type(odd) {
+        .summary dt {
+            display: flex;
+            align-items: center;
+            white-space: nowrap;
+            padding-right: 2em;
+        }
+        .summary dt:nth-of-type(odd), .summary dd:nth-of-type(odd), table.stats-details tbody tr:nth-of-type(odd) {
             background-color: #f4f4f4;
         }
-        .summary dt:nth-of-type(even), .summary dd:nth-of-type(even) {
+        .summary dt:nth-of-type(even), .summary dd:nth-of-type(even), table.stats-details tbody tr:nth-of-type(even) {
             background-color: #eaeaea;
         }
-    </style>
-@endsection
 
-@section('header')
-    <h2 class="my-4">{{ sprintf(__('Statistiques de la publication « %s »'), $fluidbook->name) }}</h2>
+        table.stats-details {
+            width: 100%;
+        }
+        table.stats-details th, table.stats-details td {
+            padding: 0.5em 1em;
+        }
+        table.stats-details thead tr {
+            position: sticky;
+            top: 0;
+            background-color: #fff;
+        }
+        table.stats-details tbody tr:hover td {
+            background-color: rgba(156, 195, 34, 0.15);
+        }
+
+        .table-columns {
+            display: flex;
+            flex-wrap: wrap;
+            gap: 2em;
+        }
+        .table-columns > div {
+            flex: 1;
+        }
+
+        .no-statistics {
+            background-color: #fefce9;
+            color: #854e18;
+            padding: 1.5em;
+            margin-top: 1.5em;
+        }
+
+        .heading-subtitle {
+            opacity: 0.6;
+        }
+        .heading-subtitle:before {
+            content: ' — ';
+        }
+
+        /*=== Date Range Picker ===*/
+        [data-daterangepicker]:hover {
+            color: #467fcf;
+        }
+        .daterangepicker .periods li:hover, .daterangepicker .periods li.active, .daterangepicker .ranges li:hover, .daterangepicker .ranges li.active {
+            background: #263340;
+        }
+        .daterangepicker .periods li, .daterangepicker .ranges li {
+            color: #467fcf;
+        }
+        .daterangepicker .custom-range-buttons button {
+            color: #467fcf;
+        }
+        .daterangepicker .custom-range-buttons button.apply-btn {
+            background: #9cc322;
+        }
+        .daterangepicker .custom-range-buttons button.apply-btn:hover {
+            background: #263340;
+        }
+
+        /*=== Table Column Sorter ===*/
+        .sortable th:not(.sorttable_nosort) {
+            cursor: pointer;
+            white-space: nowrap;
+        }
+        .sortable th:not(.sorttable_nosort):after {
+            visibility: hidden; /* only shows on actively sorted column */
+            content: '';
+            display: inline-block;
+            vertical-align: middle;
+            margin-left: 0.5em;
+            margin-top: -0.1em;
+            width: 0;
+            height: 0;
+            border-style: solid;
+            border-width: 0 0.4em 0.6em 0.4em;
+            border-color: transparent transparent currentColor transparent;
+            opacity: 0.6;
+        }
+
+        .sorttable_sorted_ascending:after {
+            visibility: visible !important;
+        }
+        .sorttable_sorted_descending:after {
+            visibility: visible !important;
+            transform: rotate(180deg);
+        }
+
+        .whitespace-nowrap {
+            white-space: nowrap;
+        }
+
+    </style>
 @endsection
 
 @section('content')
+    <h2 class="mt-4">
+        {{ __('Statistics') }}
+        <span class="heading-subtitle">{{ $fluidbook->name }}</span>
+    </h2>
+
+    <p data-daterangepicker class="mb-4" style="cursor: pointer" title="{{ __('Statistics Period - click to choose a new range') }}">
+        <i class="las la-calendar-week align-middle mr-1" style="font-size: 32px;"></i>
+        <span class="date-range-text">
+            {!! $formatted_date_range !!}
+            @if($report_timespan)
+                <span style="opacity: 0.6; display: inline-block; margin-left: 0.5em;">({{ $report_timespan }})</span>
+            @endif
+        </span>
+    </p>
 
     <dl class="summary">
-        <dt>{{ __('Creation Date') }}</dt>
-        <dd>{{ $fluidbook->created_at->isoFormat('dddd Do MMMM YYYY') }}</dd>
+        <dt>{{ __('Fluidbook Name') }}</dt>
+        <dd>{{ $fluidbook->name }}</dd>
 
-        <dt>{{ __('Total Visits') }}</dt>
-        <dd>{{ $visits->sum('nb_visits') }}</dd>
+        <dt>{{ __('Creation Date') }}</dt>
+        <dd>
+            {{ $fluidbook->created_at->isoFormat('dddd, Do MMMM YYYY') }}
+            <span style="opacity: 0.6; display: inline-block; margin-left: 0.5em;">
+                ({{ $fluidbook->created_at->diffForHumans([
+                        'parts' => 2, // How many levels of detail to go into (ie. years, months, days)
+                        'join' => true, // Join string with natural language separators for the locale
+                    ])
+                }})
+            </span>
+        </dd>
 
-        <dt>{{ __('Total Page Views') }}</dt>
-        <dd>{{ $pageviews->sum('nb_pageviews') }}</dd>
+        <dt>{{ __('Page Count') }}</dt>
+        <dd>{{ $page_count }}</dd>
 
-        {{-- TODO: get extended stats for links, sharing, etc --}}
+        @if($period_details->isNotEmpty())
+            @foreach ($table_map['summary'] as $summary_key => $summary_heading)
+                @php
+                    if ($summary_key === 'formatted_date') continue;
+                @endphp
+                <dt>{{ sprintf(__('Total %s'), $summary_heading) }}</dt>
+                <dd>{{ $formatter->format($period_details->sum($summary_key)) }}</dd>
+            @endforeach
 
+            <dt>{{ __('Total Searches') }}</dt>
+            <dd>{{ $formatter->format($searches->sum()) }}</dd>
+        @endif
     </dl>
 
-    @if ($period === 'month')
-        <h2>{{ __('Monthly Details') }}</h2>
-    @elseif ($period === 'day')
-        <h2>{{ __('Daily Details') }}</h2>
-    @endif
+    @if($period_details->isNotEmpty())
 
-    <canvas id="stats_chart"></canvas>
+        <h2 class="mt-5">{!! $chart_heading !!}</h2>
 
-    <br><br><br>
-    @foreach($available_periods as $date_key => $period_data)
-        @if (isset($period_data['URL']))
-            <a href="{{ $period_data['URL'] }}">{{ $period_data['formatted_date'] }}</a>
-        @else
-            {{ $period_data['formatted_date'] }}
-        @endif
-        <br>
-    @endforeach
+        {{-- Chart --}}
+        <canvas id="stats_chart"></canvas>
+
+        {{-- Stats for each period entry (year, month or day) --}}
+        <table class="sortable stats-details mt-5">
+            <thead>
+                <tr>
+                    @foreach ($table_map['summary'] as $summary_heading_key => $summary_heading)
+                        <th @if($summary_heading_key === 'formatted_date')class="sorttable_sorted_ascending"@endif>
+                            {{ $summary_heading }}
+                        </th>
+                    @endforeach
+                </tr>
+            </thead>
+            <tbody>
+                @foreach($period_details as $date_key => $period_data)
+                    <tr>
+                        @foreach (array_keys($table_map['summary']) as $summary_key)
+                            <td data-name="{{ $summary_key }}"
+                                @if($summary_key === 'formatted_date')data-sort-value="{{ $period_data['raw_date'] }}"@endif>
+                                {!! is_int($period_data[$summary_key]) ? $formatter->format($period_data[$summary_key]) : $period_data[$summary_key] !!}
+                            </td>
+                        @endforeach
+                    </tr>
+                @endforeach
+            </tbody>
+        </table>
+
+        {{-- Stats segregated by page number --}}
+        <h3 class="mt-5">{{ __('Details by page') }} <small>({!! $formatted_date_range !!})</small></h3>
+
+        <table class="sortable stats-details mt-3">
+            <thead>
+                <tr>
+                    @foreach ($table_map['per-page'] as $page_data_heading_key => $page_data_heading)
+                        {{-- In the case of the "page_group" data, we want it to be sorted in ascending order by default, even though it's a numeric column --}}
+                        <th @if($page_data_heading_key === 'page_group')class="sorttable_sorted_ascending"
+                            data-sort-direction="ascending"@endif>
+                            {{ $page_data_heading }}
+                        </th>
+                    @endforeach
+                </tr>
+            </thead>
+            <tbody>
+                @foreach($pages as $page_group => $page_data)
+                    <tr>
+                        @foreach (array_keys($table_map['per-page']) as $summary_key)
+                            <td data-name="{{ $summary_key }}" @if($summary_key === 'page_group')data-sort-value="{{ $page_data['page_number'] }}"@endif>
+                                {!! is_int($page_data[$summary_key]) ? $formatter->format($page_data[$summary_key]) : $page_data[$summary_key] !!}
+                            </td>
+                        @endforeach
+                    </tr>
+                @endforeach
+            </tbody>
+        </table>
+
+        {{-- Additional stats tables organised into columns (outgoing links, search keywords and countries) --}}
+        {{-- Sometimes there are no stats for certain categories, so the number of columns adapts accordingly --}}
+        <div class="table-columns mt-5">
+
+            {{-- Outgoing Links --}}
+            @if($outlinks->isNotEmpty())
+                <div>
+                    <h3>{{ __('Outgoing Links') }}</h3>
+
+                    <table class="sortable stats-details mt-3">
+                        <thead>
+                        <tr>
+                            <th>{{ __('URL') }}</th>
+                            <th class="sorttable_sorted_descending">{{ __('Clicks') }}</th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        @foreach($outlinks as $link)
+                            <tr>
+                                <td>
+                                    <a href="{{ $link['label'] }}" target="_blank" rel="noopener">{{ $link['label'] }}</a>
+                                </td>
+                                <td>{{ $formatter->format($link['nb_events']) }}</td>
+                            </tr>
+                        @endforeach
+                        </tbody>
+                    </table>
+                </div>
+            @endif
+
+            {{-- Search Keywords --}}
+            @if($searches->isNotEmpty())
+                <div>
+                    <h3>{{ __('Search Keywords') }}</h3>
+
+                    <table class="sortable stats-details mt-3">
+                        <thead>
+                        <tr>
+                            <th>{{ __('Query') }}</th>
+                            <th class="sorttable_sorted_descending">{{ __('Searches') }}</th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        @foreach($searches as $search_query => $search_count)
+                            <tr>
+                                <td class="whitespace-nowrap">{{ $search_query }}</td>
+                                <td>{{ $formatter->format($search_count) }}</td>
+                            </tr>
+                        @endforeach
+                        </tbody>
+                    </table>
+                </div>
+            @endif
+
+            {{-- Visitor Countries --}}
+            @if($countries->isNotEmpty())
+                <div>
+                    <h3>{{ __('Origin of Visitors') }}</h3>
 
-    {{--@dump($fluidbook, $visits, $pageviews, $searches)--}}
+                    <table class="sortable stats-details mt-3">
+                        <thead>
+                        <tr>
+                            <th>{{ __('Country') }}</th>
+                            <th class="sorttable_sorted_descending">{{ __('Visits') }}</th>
+                        </tr>
+                        </thead>
+                        <tbody>
+                        @foreach($countries as $country)
+                            <tr>
+                                <td class="whitespace-nowrap">
+                                    <img src="{{ $country['flag'] }}" alt="{{ $country['label'] }}" style="width: 1.5em; margin-right: 0.75em;">
+                                    {{ $country['label'] }}
+                                </td>
+                                <td>{{ $formatter->format($country['nb_visits']) }}</td>
+                            </tr>
+                        @endforeach
+                        </tbody>
+                    </table>
+                </div>
+            @endif
+
+        </div>
+
+
+    {{-- It's possible for there to be no statistics returned by the API --}}
+    @else
+        <div class="no-statistics">
+            <span style="vertical-align: middle; margin-right: 0.5em;">⚠</span>️
+            {{ __('Sorry, no statistics were found for this period.') }}
+        </div>
+    @endif
 
 @endsection
 
 @push('after_scripts')
+    {{-- Date Range picker: https://sensortower.github.io/daterangepicker/docs --}}
+    <script type="text/javascript" src="{{ asset('packages/moment/min/moment-with-locales.min.js') }}"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.5.1/knockout-latest.min.js" integrity="sha512-vs7+jbztHoMto5Yd/yinM4/y2DOkPLt0fATcN+j+G4ANY2z4faIzZIOMkpBmWdcxt+596FemCh9M18NUJTZwvw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
+    <script type="text/javascript" src="{{ asset('packages/daterangepicker/daterangepicker.js') }}"></script>
+    <script>
+        moment.locale('{{ $locale }}');
+         @if ($locale === 'en')
+             moment.updateLocale('{{ $locale }}', {
+                 longDateFormat: {
+                     // Date range picker uses the 'L' format for displaying dates
+                     L: 'DD/MM/YYYY', // We don't like the default, backwards US date format
+                 }
+             });
+        @endif
+
+        $('[data-daterangepicker]').daterangepicker({
+            callback: function(startDate, endDate, period) {
+                let range = startDate.format('YYYY-MM-DD') + ',' + endDate.format('YYYY-MM-DD');
+                location.href = `{{ $base_URL }}/${range}`;
+            },
+            forceUpdate: false,
+            minDate: '{{ $fluidbook->created_at->isoFormat('YYYY-MM-DD') }}', // Creation date of the Fluidbook
+            startDate: '{{ $start_date }}',
+            endDate: '{{ $end_date }}',
+
+            {{-- TODO: add translations - check https://github.com/sensortower/daterangepicker/issues/42 --}}
+            {{--
+            locale: {
+                applyButtonTitle: '{{ __('Apply')  }}',
+                cancelButtonTitle: '{{ __('Cancel')  }}',
+                startLabel: '{{ __('Start')  }}',
+                endLabel: '{{ __('Start')  }}',
+            }
+            --}}
+        });
+
+    </script>
+
+    {{--============================================================================================================--}}
+
     {{-- Charting library --}}
     <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js" integrity="sha512-ElRFoEQdI5Ht6kZvyzXhYG9NqjtkmlkfYk0wr6wHxU9JEHakS7UJZNeml5ALk+8IKlU6jDgMabC3vkumRokgJA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
 
-
     <script>
         //=== Chart Setup
         const labels = {!! json_encode($labels) !!};
                     backgroundColor: 'hsl(72 100% 38% / 100%)',
                     // borderColor: 'hsl(72 100% 38%)',
                     // borderWidth: 1,
-                    data: {!! json_encode($visits->pluck('nb_visits')->toArray()) !!},
+                    data: {!! json_encode($pagesByPeriod->pluck('nb_visits')->toArray()) !!},
                     order: 1,
                 },
                 {
                     backgroundColor: 'hsl(0 0% 53% / 100%)',
                     // borderColor: 'hsl(0 0% 53%)',
                     // borderWidth: 1,
-                    data: {!! json_encode($pageviews->pluck('nb_pageviews')->toArray()) !!},
+                    data: {!! json_encode($pagesByPeriod->pluck('nb_hits')->toArray()) !!},
                     order: 2,
                 },
             ]
 
     </script>
 
+    {{--============================================================================================================--}}
+
+    {{-- Simple Table Sorter --}}
+    <script type="text/javascript" src="{{ asset('packages/sorttable/sorttable.js') }}"></script>
+    {{-- This script works on any tables with the "sortable" class. There's no extra setup needed here. --}}
+
 @endpush