]> _ Git - fluidbook-toolbox.git/commitdiff
Added unique visitor stats for newer Fluidbooks, refactored code and improved loading...
authorStephen Cameron <stephen@cubedesigners.com>
Mon, 26 Sep 2022 17:53:59 +0000 (19:53 +0200)
committerStephen Cameron <stephen@cubedesigners.com>
Mon, 26 Sep 2022 17:53:59 +0000 (19:53 +0200)
app/Http/Controllers/Admin/Operations/FluidbookPublication/StatsOperation.php
resources/views/fluidbook_stats/loader.blade.php [new file with mode: 0644]
resources/views/fluidbook_stats/summary.blade.php

index 54133d81aaeb5a94b17c48f94deaf3b0f47320e1..930a005e57994e74c8cad2da9b8a0634c8f64b96 100644 (file)
@@ -15,10 +15,16 @@ trait StatsOperation
     protected function setupStatsRoutes($segment, $routeName, $controller)
     {
         // Main route is only secured by hash (security by obscurity)
-        Route::get($segment . '/stats/{fluidbook_id}_{hash}/{date?}/{period_override?}', $controller . '@statsSummary')
+        Route::get($segment . '/stats/{fluidbook_id}_{hash}/{date?}/{period_override?}', $controller . '@statsLoader')
             ->withoutMiddleware([CheckIfAdmin::class])
             ->name('stats'); // Named route is used to generate URLs more consistently using route helper
 
+        // Since the stats can sometimes be slow to generate, the initial URL displays
+        // a loader while the actual report is fetched via AJAX and injected into the page.
+        Route::get($segment . '/stats-data/{fluidbook_id}_{hash}/{date?}/{period_override?}', $controller . '@statsSummary')
+            ->withoutMiddleware([CheckIfAdmin::class])
+            ->name('stats-report');
+
         // API testing tool (intended for superadmins only)
         Route::get($segment . '/stats/API', $controller . '@statsAPI');
 
@@ -129,7 +135,7 @@ trait StatsOperation
         // Count 2+ years: period = year
         $day_count = Carbon::parse($start_date)->diffInDays($end_date);
 
-        if ($day_count <= 50) {
+        if ($day_count <= 60) {
             return 'day';
         } elseif ($day_count <= 180) {
             return 'week';
@@ -140,6 +146,11 @@ trait StatsOperation
         return 'year';
     }
 
+    protected function statsLoader($fluidbook_id, $hash, $date = null, $period_override = null) {
+        $report_URL = route('stats-report', compact('fluidbook_id', 'hash', 'date', 'period_override'));
+        return view('fluidbook_stats.loader', compact('report_URL'));
+    }
+
     protected function statsSummary($fluidbook_id, $hash, $date = null, $period_override = null) {
 
         $locale = app()->getLocale();
@@ -264,9 +275,15 @@ trait StatsOperation
         // types of reports but for now, they have to be filtered.
 
         // * Note about unique visitors:
-        // We added unique visitor stats but found that they were only different to the 'visits' count in our historical
-        // data from the old stats system. With Matomo, the uniques always seem to be the same as the visits, so they
-        // don't serve any useful purpose here, only highlighting the inconsistencies with the historical stats.
+        // During the transition from the old stats system to Matomo, there has been a proxy in place that transfers
+        // stats from older Fluidbooks to Matomo (necessary because these didn't have the Matomo tracker embedded).
+        // However, the old system is poor at determining unique visitors, meaning that any "uniques" we received from
+        // there should be considered normal visits. When displaying the stats, we can only display unique visitors if
+        // they came from the native Matomo tracker. We'll determine this based on the Fluidbook ID >= 20687, which
+        // was the first one for 2022, when we're sure that the new tracker was in place. More details:
+        // - https://redmine.cubedesigners.com/issues/5474#note-5
+        // - https://redmine.cubedesigners.com/issues/5473
+        $new_stats_cutoff = 20687; // Starting Fluidbook ID for new stats system
 
         //=== 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).
@@ -315,21 +332,21 @@ trait StatsOperation
                 $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_unique_visits' => $pageUrls["/page/$page_number"]['sum_daily_nb_uniq_visitors'] ?? 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),
+                    'page_group'       => $page_group,
+                    'page_number'      => $page_number, // Used by table column sorter
+                    'nb_visits'        => $pageUrls["/page/$page_number"]['nb_visits'] ?? 0,
+                    'nb_uniq_visitors' => $pageUrls["/page/$page_number"]['sum_daily_nb_uniq_visitors'] ?? 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_unique_visits'] = $pageUrls["/page/0"]['sum_daily_nb_uniq_visitors'] ?? 0;
+                    $pages[$page_group]['nb_uniq_visitors'] = $pageUrls["/page/0"]['sum_daily_nb_uniq_visitors'] ?? 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);
 
@@ -343,17 +360,17 @@ trait StatsOperation
                 $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_unique_visits' => $pageUrls["/page/$page_number"]['sum_daily_nb_uniq_visitors'] ?? 0,
-                    'nb_pageviews' => $pageUrls["/page/$page_number"]['nb_hits'] ?? 0,
-                    'nb_zooms'     => data_get($eventsByPage, "zoom.subtable.$page_number.nb_events", 0),
+                    'page_group'       => $page_group,
+                    'page_number'      => $page_number, // Used by table column sorter
+                    'nb_visits'        => $pageUrls["/page/$page_number"]['nb_visits'] ?? 0,
+                    'nb_uniq_visitors' => $pageUrls["/page/$page_number"]['sum_daily_nb_uniq_visitors'] ?? 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),
+                    '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),
                 ];
             }
         }
@@ -403,11 +420,12 @@ trait StatsOperation
         //=== 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)
-        // Note: in order to get the total "nb_uniq_visitors" for the pages, we would need to fetch
+        // Note: in order to get the total "nb_uniq_visitors" for the pages, we need to fetch
         // the expanded dataset that includes the subtables of pages. For some reason, Matomo
         // doesn't aggregrate this value when there are sub pages, so we have to do it ourselves
-        $pagesByPeriod = collect($report->getPageUrls(['expanded' => 0]))
-            ->map(function ($item, $date) use ($period, $fluidbook_id, $hash, $eventsByPeriod) {
+        $expanded_stats = $fluidbook_id >= $new_stats_cutoff ? 1 : 0; // Only fetch extra data when it will be used
+        $pagesByPeriod = collect($report->getPageUrls(['expanded' => $expanded_stats]))
+            ->map(function ($item, $date) use ($period, $fluidbook_id, $hash, $eventsByPeriod, $new_stats_cutoff) {
 
             if (empty($item)) {
                 return $item; // Some periods might have no data
@@ -427,13 +445,15 @@ trait StatsOperation
             // the returned data flatter and easier to process later for sums etc.
             $data = $labelled['page'];
 
-            // Unique visitors stats disabled - see notes above.
-            // // Matomo doesn't provide an aggregate of unique visitors, so we must do the sum ourselves.
-            // // If the period is "day", the number of unique visitors will be in the key 'nb_uniq_visitors'
-            // // but if it is a longer period (week, month, year) then the key becomes 'sum_daily_nb_uniq_visitors'
-            // $unique_visitors_key = $period === 'day' ? 'nb_uniq_visitors' : 'sum_daily_nb_uniq_visitors';
-            // $subpages = collect($data['subtable'] ?? []);
-            // $data['nb_uniq_visitors'] = $subpages->sum($unique_visitors_key);
+            //== Unique Visitors
+            // Matomo doesn't provide an aggregate of unique visitors, so we must do the sum ourselves.
+            // If the period is "day", the number of unique visitors will be in the key 'nb_uniq_visitors'
+            // but if it is a longer period (week, month, year) then the key becomes 'sum_daily_nb_uniq_visitors'
+            if($fluidbook_id >= $new_stats_cutoff) {
+                $unique_visitors_key = $period === 'day' ? 'nb_uniq_visitors' : 'sum_daily_nb_uniq_visitors';
+                $subpages = collect($data['subtable'] ?? []);
+                $data['nb_uniq_visitors'] = $subpages->sum($unique_visitors_key);
+            }
 
             $data['raw_date'] = $date; // We still need the raw date for sorting and other formatting purposes
 
@@ -465,6 +485,13 @@ trait StatsOperation
             return $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 + detailed data table
+        $period_details = $pagesByPeriod->filter(fn ($value, $key) => !empty($value)); // Remove any empty periods
+
         //=== CHART PREPARATION
         // Format dates for display as labels on the x-axis
         $labels = $pagesByPeriod->keys()->map(function ($label, $index) use ($period, $start_date, $end_date) {
@@ -478,19 +505,53 @@ trait StatsOperation
 
         $tooltip_labels = array_combine($labels, $formatted_dates);
 
-        //=== 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 + detailed data table
-        $period_details = $pagesByPeriod->filter(fn ($value, $key) => !empty($value)); // Remove any empty periods
+        $chart_datasets = [
+            [
+                'label' => __('Visits'),
+                'backgroundColor' => 'hsl(72 100% 38% / 100%)', // Could make bars semi-transparent if desired
+                // borderColor: 'hsl(72 100% 38%)',
+                // borderWidth: 1,
+                'data' => $pagesByPeriod->pluck('nb_visits')->toArray(),
+                'order' => 1,
+                'bar_offset' => -5, // Negative offset shifts bar to left
+            ],
+            [
+                'label' => __('Page Views'),
+                'backgroundColor' => 'hsl(0 0% 53%)',
+                'data' => $pagesByPeriod->pluck('nb_hits')->toArray(),
+                'order' => 2,
+                'bar_offset' => 5, // Positive offset shifts bar to right
+            ],
+        ];
+
+        if ($fluidbook_id >= $new_stats_cutoff) {
+            // Insert the unique visitors dataset at the beginning of the array
+            $chart_datasets = array_merge([
+                [
+                    'label' => __('Unique Visitors'),
+                    'backgroundColor' => 'hsl(19 100% 48% / 100%)',
+                    'data' => $pagesByPeriod->pluck('nb_uniq_visitors')->toArray(),
+                    'order' => 1,
+                    'bar_offset' => -8, // Negative offset shifts bar to left
+                ]
+            ], $chart_datasets);
+
+            // Now that we have 3 bars, we need to update the order and bar offsets for the visits and page views
+            $chart_datasets[1]['order'] = 2;
+            $chart_datasets[1]['bar_offset'] = 0; // This will be the middle bar, so no offset
+
+            $chart_datasets[2]['order'] = 3;
+            $chart_datasets[2]['bar_offset'] = 8;
+        }
+
+        $chart_datasets = json_encode($chart_datasets, JSON_UNESCAPED_SLASHES);
 
         // Map of API data to table headings (used to display summaries under the chart)
         $table_map = [
             // Main summary table
             'summary' => [
                 'formatted_date'   => $period_map[$period],
-                // 'nb_uniq_visitors' => __('Unique Visitors'),
+                'nb_uniq_visitors' => __('Unique Visitors'),
                 'nb_visits'        => __('Visits'),
                 'nb_hits'          => __('Pages Viewed'),
                 'nb_links'         => __('Outgoing Links'),
@@ -501,7 +562,7 @@ trait StatsOperation
             // Per-page detail table
             'per-page' => [
                 'page_group'       => __('Pages'),
-                // 'nb_unique_visits' => __('Unique Visits'),
+                'nb_uniq_visitors' => __('Unique Visits'),
                 'nb_visits'        => __('Visits'),
                 'nb_pageviews'     => __('Views'),
                 'nb_zooms'         => __('Zooms'),
@@ -510,6 +571,12 @@ trait StatsOperation
             ],
         ];
 
+        // Older Fluidbooks can't show unique visitors (see notes above)
+        if ($fluidbook_id < $new_stats_cutoff) {
+            unset($table_map['summary']['nb_uniq_visitors']);
+            unset($table_map['per-page']['nb_uniq_visitors']);
+        }
+
         $formatter = NumberFormatter::create($locale, NumberFormatter::DEFAULT_STYLE);
 
         return view('fluidbook_stats.summary',
@@ -526,6 +593,7 @@ trait StatsOperation
                 'tooltip_labels',
                 'formatted_date_range',
                 'chart_heading',
+                'chart_datasets',
                 'searches',
                 'outlinks',
                 'countries',
@@ -536,7 +604,6 @@ trait StatsOperation
                 'period_map',
                 'table_map',
                 'pages',
-                'pagesByPeriod',
                 'formatter',
                 'locale',
                 'base_URL',
@@ -587,9 +654,9 @@ trait StatsOperation
             if ($crosses_year_boundary) {
                 $week_formatted = $week_start->isoFormat('Do MMMM, YYYY') .' — '. $week_end->isoFormat('Do MMMM, YYYY');
             } elseif ($crosses_month_boundary) {
-                $week_formatted = $week_start->isoFormat('Do MMMM') .' — '. $week_end->isoFormat('Do MMMM');
+                $week_formatted = $week_start->isoFormat('Do MMMM') .' — '. $week_end->isoFormat('Do MMMM, YYYY');
             } else {
-                $week_formatted = $week_start->isoFormat('Do') .'—'. $week_end->isoFormat('Do MMMM');
+                $week_formatted = $week_start->isoFormat('Do') .'—'. $week_end->isoFormat('Do MMMM, YYYY');
             }
         }
 
diff --git a/resources/views/fluidbook_stats/loader.blade.php b/resources/views/fluidbook_stats/loader.blade.php
new file mode 100644 (file)
index 0000000..f90ee63
--- /dev/null
@@ -0,0 +1,244 @@
+@extends(backpack_view('blank'))
+
+@section('after_styles')
+    <link rel="stylesheet" href="{{ asset('packages/daterangepicker/daterangepicker.css') }}">
+    <style>
+        @keyframes spinner {
+            to {
+                transform: rotate(360deg);
+            }
+        }
+
+        #stats_loader:before {
+            content: '';
+            box-sizing: border-box;
+            width: 3em;
+            height: 3em;
+            border-radius: 50%;
+            border: 2px solid #ccc;
+            border-top-color: #1b2a4e;
+            animation: spinner .6s linear infinite;
+        }
+
+        #stats_loader {
+            display: flex;
+            min-height: 50vh;
+            align-items: center;
+            justify-content: center;
+            flex-direction: column;
+            gap: 1em;
+            padding: 2em;
+            color: #aaa;
+            font-size: 20px;
+        }
+
+        #stats_error {
+            background-color: #fff5f5;
+            color: #c53030;
+            border: 1px solid #fc8181;
+            border-radius: 0.25em;
+            padding: 0.75em 1em;
+            display: block;
+            width: max-content;
+            margin: 5em auto;
+        }
+
+
+        .periods {
+            display: inline-flex;
+            margin-left: 2em;
+        }
+        .periods > * {
+            padding: 0.2em 0.8em;
+            border: 1px solid #ddd;
+        }
+        .periods > * + * {
+            border-left: 0;
+        }
+        .periods span {
+            background-color: #eaeaea;
+        }
+        .periods a {
+            background-color: #f4f4f4;
+        }
+        .periods a:hover {
+            background-color: #467fcf;
+            color: #fff;
+            text-decoration: none;
+        }
+
+        .summary {
+            display: grid;
+            grid-template-columns: max-content 1fr;
+        }
+        .summary dt, .summary dd {
+            margin: 0;
+            padding: 0.5em;
+        }
+        .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), table.stats-details tbody tr:nth-of-type(even) {
+            background-color: #eaeaea;
+        }
+
+        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')
+    <div id="stats_loader">Loading...</div>
+    <div id="stats_wrapper"></div>
+    <div id="stats_error" style="display: none">
+        {{ __('Sorry, an error occured while fetching this report. Please try again later. ') }}
+    </div>
+@endsection
+
+@push('after_scripts')
+    {{-- Fetch the report and inject it into the page when ready... This is to improve the UX --}}
+    <script>
+
+        fetch('{{ $report_URL }}').then(function (response) {
+            if (!response.ok) {
+                throw Error(`${response.statusText} (${response.status}) - ${response.url}`);
+            }
+            return response.text();
+        }).then(function (html) {
+            hideStatsLoader();
+            setInnerHTML(document.getElementById('stats_wrapper'), html);
+        }).catch(function (err) {
+            console.warn('Unable to load stats report', err);
+            showStatsError();
+        });
+
+
+        function hideStatsLoader() {
+            document.getElementById('stats_loader').style.display = 'none';
+        }
+
+        function showStatsError() {
+            hideStatsLoader();
+            document.getElementById('stats_error').style.display = 'block';
+        }
+
+        // Set the inner HTML of an element AND make any script tags executable
+        // Ref: https://stackoverflow.com/a/47614491
+        function setInnerHTML(elm, html) {
+            elm.innerHTML = html;
+            Array.from(elm.querySelectorAll("script")).forEach( oldScript => {
+                const newScript = document.createElement("script");
+                Array.from(oldScript.attributes)
+                    .forEach( attr => newScript.setAttribute(attr.name, attr.value) );
+                newScript.appendChild(document.createTextNode(oldScript.innerHTML));
+                oldScript.parentNode.replaceChild(newScript, oldScript);
+            });
+        }
+
+    </script>
+
+    {{-- Include the base libraries for the chart and the date picker here so that they're ready
+    when the report HTML and extra scripts are injected --}}
+
+    {{-- 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>
+
+    {{-- 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>
+@endpush
index d0176578f6a7dab4bab1e7f4a1c2464a6cde071e..0deba1d52ff105e6adcd5049d83f9fef8280c674 100644 (file)
-@extends(backpack_view('blank'))
-
-@section('after_styles')
-    <link rel="stylesheet" href="{{ asset('packages/daterangepicker/daterangepicker.css') }}">
-    <style>
-        .periods {
-            display: inline-flex;
-            margin-left: 2em;
-        }
-        .periods > * {
-            padding: 0.3em 1em;
-            border: 1px solid #ddd;
-        }
-        .periods span {
-            background-color: #eaeaea;
-        }
-        .periods a {
-            background-color: #f4f4f4;
-        }
-        .periods a:hover {
-            background-color: #467fcf;
-            color: #fff;
-            text-decoration: none;
-        }
-
-        .summary {
-            display: grid;
-            grid-template-columns: max-content 1fr;
-        }
-        .summary dt, .summary dd {
-            margin: 0;
-            padding: 0.5em;
-        }
-        .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), table.stats-details tbody tr:nth-of-type(even) {
-            background-color: #eaeaea;
-        }
-
-        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>
-
-    <span data-daterangepicker class="mb-2" 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>
+{{-- Statistics Report --}}
+{{-- This doesn't extend any templates because it is fetched from loader.blade.php and injected via JS --}}
+
+<h2 class="mt-4">
+    {{ __('Statistics') }}
+    <span class="heading-subtitle">{{ $fluidbook->name }}</span>
+</h2>
+
+<span data-daterangepicker class="mb-2" style="cursor: pointer" title="{{ __('Statistics Period - click to choose a new date 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>
+</span>
+
+<div class="periods mb-4">
+    @foreach($period_map as $period_key => $period_title)
+        @if($period_key === $period)
+            <span>{{ $period_title }}</span>
+        @else
+            <a href="{{ route('stats', compact('fluidbook_id', 'hash') + ['date' => $date ?? '-', 'period_override' => $period_key]) }}">
+                {{ $period_title }}
+            </a>
+        @endif
+    @endforeach
+</div>
+
+
+<dl class="summary">
+    <dt>{{ __('Fluidbook Name') }}</dt>
+    <dd>{{ $fluidbook->name }}</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>
 
-    <div class="periods mb-4">
-        @foreach($period_map as $period_key => $period_title)
-            @if($period_key === $period)
-                <span>{{ $period_title }}</span>
-            @else
-                <a href="{{ route('stats', compact('fluidbook_id', 'hash') + ['date' => $date ?? '-', 'period_override' => $period_key]) }}">
-                    {{ $period_title }}
-                </a>
-            @endif
-        @endforeach
-    </div>
-
+    <dt>{{ __('Page Count') }}</dt>
+    <dd>{{ $page_count }}</dd>
 
-    <dl class="summary">
-        <dt>{{ __('Fluidbook Name') }}</dt>
-        <dd>{{ $fluidbook->name }}</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>{{ __('Page Count') }}</dt>
-        <dd>{{ $page_count }}</dd>
-
-        @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
+    @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>
+        <dt>{{ __('Total Searches') }}</dt>
+        <dd>{{ $formatter->format($searches->sum()) }}</dd>
+    @endif
+</dl>
 
-    @if($period_details->isNotEmpty())
+@if($period_details->isNotEmpty())
 
-        <h2 class="mt-5">{!! $chart_heading !!}</h2>
+    <h2 class="mt-5">{!! $chart_heading !!}</h2>
 
-        {{-- Chart --}}
-        <canvas id="stats_chart"></canvas>
+    {{-- Chart --}}
+    <canvas id="stats_chart"></canvas>
 
-        {{-- Stats for each period entry (year, month or day) --}}
-        <table class="sortable stats-details mt-5">
-            <thead>
+    {{-- 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 ($table_map['summary'] as $summary_heading_key => $summary_heading)
-                        <th @if($summary_heading_key === 'formatted_date')class="sorttable_sorted_ascending"@endif>
-                            {{ $summary_heading }}
-                        </th>
+                    @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>
-            </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
-            </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>
+        </thead>
+        <tbody>
+            @foreach($pages as $page_group => $page_data)
                 <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>
+                    @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>
-            </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>
+            @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">
+    {{-- 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>
+        {{-- Outgoing Links --}}
+        @if($outlinks->isNotEmpty())
+            <div>
+                <h3>{{ __('Outgoing Links') }}</h3>
 
-                    <table class="sortable stats-details mt-3">
-                        <thead>
+                <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>
-                            <th>{{ __('URL') }}</th>
-                            <th class="sorttable_sorted_descending">{{ __('Clicks') }}</th>
+                            <td>
+                                <a href="{{ $link['label'] }}" target="_blank" rel="noopener">{{ $link['label'] }}</a>
+                            </td>
+                            <td>{{ $formatter->format($link['nb_events']) }}</td>
                         </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>
+                    @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>
-                            <th>{{ __('Query') }}</th>
-                            <th class="sorttable_sorted_descending">{{ __('Searches') }}</th>
+                            <td class="whitespace-nowrap">{{ $search_query }}</td>
+                            <td>{{ $formatter->format($search_count) }}</td>
                         </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>
-
-                    <table class="sortable stats-details mt-3">
-                        <thead>
+                    @endforeach
+                    </tbody>
+                </table>
+            </div>
+        @endif
+
+        {{-- Visitor Countries --}}
+        @if($countries->isNotEmpty())
+            <div>
+                <h3>{{ __('Origin of Visitors') }}</h3>
+
+                <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>
-                            <th>{{ __('Country') }}</th>
-                            <th class="sorttable_sorted_descending">{{ __('Visits') }}</th>
+                            <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>
-                        </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>
+                    @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
+
+
+{{--================== SCRIPTS ==================--}}
+
+{{-- Date Range picker setup: https://sensortower.github.io/daterangepicker/docs --}}
+<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
 
-@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}`;
+
+            // TODO: add loading spinner after changing the date so that we know something is happening while the next page loads...
+
+        },
+        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')  }}',
+        }
+        --}}
+    });
 
-        $('[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) !!};
-        const tooltip_labels = {!! json_encode($tooltip_labels) !!};
-        const data = {
-            labels: labels,
-            datasets: [
-                {{-- Unique visitors data was added to graph then removed because Matomo's tracker
-                doesn't seem to record any real difference compared to normal visits --}}
-                {{--
-                {
-                    label: 'Unique Visitors',
-                    backgroundColor: 'hsl(19 100% 48% / 100%)',
-                    // borderColor: 'hsl(19 100% 48%)',
-                    // borderWidth: 1,
-                    data: {!! json_encode($pagesByPeriod->pluck('nb_uniq_visitors')->toArray()) !!},
-                    order: 1,
-                    bar_offset: -8, // Negative offset shifts bar to left
-                },
-                --}}
-                {
-                    label: 'Visits',
-                    backgroundColor: 'hsl(72 100% 38% / 100%)',
-                    // borderColor: 'hsl(72 100% 38%)',
-                    // borderWidth: 1,
-                    data: {!! json_encode($pagesByPeriod->pluck('nb_visits')->toArray()) !!},
-                    order: 2,
-                    bar_offset: -5, // Negative offset shifts bar to left
-                },
-                {
-                    label: 'Page Views',
-                    backgroundColor: 'hsl(0 0% 53% / 100%)',
-                    // borderColor: 'hsl(0 0% 53%)',
-                    // borderWidth: 1,
-                    data: {!! json_encode($pagesByPeriod->pluck('nb_hits')->toArray()) !!},
-                    order: 3,
-                    bar_offset: 5, // Positive offset shifts bar to right
-                },
-            ]
-        };
-
-        //=== Plugins
-        //== Offset Bars Plugin: creates a small offset between stacked bars
-        const offsetBars = {
-            id: 'offsetBars',
-            beforeDatasetsDraw(chart, args, options) {
-
-                // Create an offset between the stacked bars
-                chart.config.data.datasets.forEach(function(dataset, datasetIndex) {
-
-                    const bar_offset_percentage = dataset.bar_offset || 0;
-
-                    // Go through each data point (bar) and apply the horizontal positioning offset
-                    chart.getDatasetMeta(datasetIndex).data.forEach(function(dataPoint, index) {
-
-                        let offset = Math.round(dataPoint.width * bar_offset_percentage / 100);
-
-                        // Make sure offset amount isn't too small
-                        if (offset > 0 && offset < 2) {
-                            offset = 2;
-                        } else if(offset < 0 && offset > -2) {
-                            offset = -2;
-                        }
+</script>
 
-                        dataPoint.x = chart.scales.x.getPixelForValue(index) + offset;
-                    });
-                });
-            }
-        };
-
-        //=== Chart Configuration
-        const config = {
-            type: 'bar',
-            data: data,
-            options: {
-                // The best way to make the chart responsive is to set the aspect ratio without setting any sizes on
-                // the canvas element itself. This way, Chart JS will manage the size, and it will behave much like a
-                // responsive image - it will take the available width, scaling the height proportionally.
-                // The aspectRatio value is a measure of the width compared to the height. In other words:
-                // aspectRatio: 3 = 3x the width compared to height
-                // aspectRatio: 1 = square
-                // and an aspectRatio less than 1 will result in a canvas that is taller than it is wide.
-                aspectRatio: 3,
-                responsive: true, // Default for Chart JS but defining it explicitly here for clarity
-                maintainAspectRatio: true, // As above, also a library default
-                maxBarThickness: 100, // Prevent bars being ridiculously wide when there isn't much data
-                scales: {
-                    x: { stacked: true },
-                    y: { stacked: false }, // Don't stack y-axis: prevents datasets from adding
-                },
-                plugins: {
-                    tooltip: {
-                        mode: 'index',
-                        position: 'nearest',
-                        callbacks: {
-                            title: function(context) {
-                                return tooltip_labels[context[0].label];
-                            }
-                        },
+{{--============================================================================================================--}}
+
+{{-- Charting library --}}
+<script>
+    //=== Chart Setup
+    const labels = {!! json_encode($labels) !!};
+    const tooltip_labels = {!! json_encode($tooltip_labels) !!};
+    const data = {
+        labels: labels,
+        datasets: {!! $chart_datasets !!}
+    };
+
+    //=== Plugins
+    //== Offset Bars Plugin: creates a small offset between stacked bars
+    const offsetBars = {
+        id: 'offsetBars',
+        beforeDatasetsDraw(chart, args, options) {
+
+            // Create an offset between the stacked bars
+            chart.config.data.datasets.forEach(function(dataset, datasetIndex) {
+
+                const bar_offset_percentage = dataset.bar_offset || 0;
+
+                // Go through each data point (bar) and apply the horizontal positioning offset
+                chart.getDatasetMeta(datasetIndex).data.forEach(function(dataPoint, index) {
+
+                    let offset = Math.round(dataPoint.width * bar_offset_percentage / 100);
+
+                    // Make sure offset amount isn't too small
+                    if (offset > 0 && offset < 2) {
+                        offset = 2;
+                    } else if(offset < 0 && offset > -2) {
+                        offset = -2;
                     }
-                }
-            },
-            plugins: [offsetBars]
-        };
 
-        //=== Render Chart
-        const statsChart = new Chart(
-            document.getElementById('stats_chart'),
-            config
-        );
+                    dataPoint.x = chart.scales.x.getPixelForValue(index) + offset;
+                });
+            });
+        }
+    };
+
+    //=== Chart Configuration
+    const config = {
+        type: 'bar',
+        data: data,
+        options: {
+            // The best way to make the chart responsive is to set the aspect ratio without setting any sizes on
+            // the canvas element itself. This way, Chart JS will manage the size, and it will behave much like a
+            // responsive image - it will take the available width, scaling the height proportionally.
+            // The aspectRatio value is a measure of the width compared to the height. In other words:
+            // aspectRatio: 3 = 3x the width compared to height
+            // aspectRatio: 1 = square
+            // and an aspectRatio less than 1 will result in a canvas that is taller than it is wide.
+            aspectRatio: 3,
+            responsive: true, // Default for Chart JS but defining it explicitly here for clarity
+            maintainAspectRatio: true, // As above, also a library default
+            maxBarThickness: 100, // Prevent bars being ridiculously wide when there isn't much data
+            scales: {
+                x: { stacked: true },
+                y: { stacked: false }, // Don't stack y-axis: prevents datasets from adding
+            },
+            plugins: {
+                tooltip: {
+                    mode: 'index',
+                    position: 'nearest',
+                    callbacks: {
+                        title: function(context) {
+                            return tooltip_labels[context[0].label];
+                        }
+                    },
+                }
+            }
+        },
+        plugins: [offsetBars]
+    };
 
-    </script>
+    //=== Render Chart
+    const statsChart = new Chart(
+        document.getElementById('stats_chart'),
+        config
+    );
 
-    {{--============================================================================================================--}}
+</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
+{{-- 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. --}}