]> _ Git - fluidbook-toolbox.git/commitdiff
WIP #5316 @27
authorStephen Cameron <stephen@cubedesigners.com>
Thu, 25 Aug 2022 17:25:58 +0000 (19:25 +0200)
committerStephen Cameron <stephen@cubedesigners.com>
Thu, 25 Aug 2022 17:25:58 +0000 (19:25 +0200)
app/Http/Controllers/Admin/Operations/FluidbookPublication/StatsOperation.php
resources/views/fluidbook_stats/API.blade.php
resources/views/fluidbook_stats/summary.blade.php
resources/views/vendor/backpack/crud/buttons/fluidbook_publication/stats.blade.php

index ff4e3de39b50024b242fda698494314e8cbe5da0..7c68a0aaf534c9070c26b5965332777f610b8ada 100644 (file)
@@ -14,7 +14,9 @@ trait StatsOperation
     {
         Route::get($segment . '/stats/API', $controller . '@statsAPI');
         // Route is only secured by hash
-        Route::get($segment . '/stats/{id}_{hash}/{date?}', $controller . '@statsSummary')->withoutMiddleware([CheckIfAdmin::class]);
+        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
     }
 
     protected function setupStatsDefaults()
@@ -124,6 +126,14 @@ trait StatsOperation
             $mode = 'overview';
             $start_date = $fluidbook->created_at->isoFormat('YYYY-MM-DD');
             $date_range = $start_date . ',' . date('Y-m-d');
+
+            // 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
+            // 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
+            }
+
             $period = 'month'; // Segregate stats by month
         }
 
@@ -131,7 +141,7 @@ trait StatsOperation
 
         $report = $this->_getReporting($fluidbook_id);
 
-        echo "Getting stats for date range $date_range, segregated by $period";
+        // 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));
@@ -142,35 +152,37 @@ trait StatsOperation
         $searches = collect($report->getSearchKeywords($fluidbook_id, $date_range, 'range'));
 
         // Format dates for display as labels on the x-axis
-        $labels = $visits->keys()->map(function ($label, $index) use ($period) {
+        $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' => Carbon::parse($label)->isoFormat('MMM'), // Convert to abbreviated month name
+                '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,
             };
         })->toArray();
 
         // Format dates for display in the tooltip title
         $formatted_dates = $visits->keys()->map(function ($label, $index) use ($period) {
-            return match ($period) {
-                'day' => Carbon::parse($label)->isoFormat('dddd Do MMMM YYYY'),
-                'month' => Carbon::parse($label)->isoFormat('MMMM YYYY'),
-                default => $label,
-            };
+            return $this->formatDateForPeriod($label, $period);
         })->toArray();
 
-//        $chart = Chartisan::build()
-//            ->labels($labels)
-//            ->extra(['tooltip_labels' => array_combine($labels, $formatted_dates)])
-//            ->dataset('Visits', $visits->pluck('nb_visits')->toArray())
-//            ->dataset('Page Views', $pageviews->pluck('nb_pageviews')->toArray())
-//            ->toJSON();
+        $tooltip_labels = array_combine($labels, $formatted_dates);
+
+        // Generate a list of available periods, based on the visits API data
+        // 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
 
-        // header('Content-Type: application/json; charset=utf-8');
-        // return $chart;
+                                        if ($period === 'month') {
+                                            $item['URL'] = route('stats', compact('fluidbook_id', 'hash', 'date')); // Generate URL using named route
+                                        }
 
-        return view('fluidbook_stats.summary', compact('fluidbook', 'visits', 'pageviews', 'searches', /*'chart',*/ 'mode', 'period', 'dates'));
+                                        return $item;
+                                    });
 
+        return view('fluidbook_stats.summary', compact('fluidbook', 'labels', 'tooltip_labels', 'visits', 'pageviews', 'searches', 'mode', 'period', 'dates', 'available_periods'));
 
     }
 
@@ -187,4 +199,12 @@ trait StatsOperation
         return view('fluidbook_stats.API', compact('matomo_tokens'));
     }
 
+    protected function formatDateForPeriod($date, $period) {
+        return match ($period) {
+            'day' => Carbon::parse($date)->isoFormat('dddd Do MMMM YYYY'),
+            'month' => Carbon::parse($date)->isoFormat('MMMM YYYY'),
+            default => $date,
+        };
+    }
+
 }
index 895b71ca56b5556b2997c5990797523c400ee542..c296de81840e44f2e85ac8185ff34c1c28ab8954 100644 (file)
             color: #fff;
         }
 
+        /* Spacing + dividers between results */
+        .API-result + .API-result {
+            margin-top: 1.5rem;
+            padding-top: 1.5rem;
+            border-top: 1px solid #ccc;
+        }
+
     </style>
 </head>
 <body class="bg-gray-600 p-4">
@@ -88,7 +95,7 @@
         </div>
     </form>
 
-    <div x-ref="result" class="flex-1 bg-white rounded-lg p-4 overflow-y-scroll"></div>
+    <div x-ref="result" class="flex-1 bg-white rounded-lg px-4 py-6 overflow-y-scroll"></div>
 
 </div>
 
               }
 
               let requestDetails = `<h2 class="mb-4 font-medium text-sm">
-                Results for <span class="text-blue-600">${$this.method}</span>
-                (date: <span class="text-blue-600">${$this.date}</span>,
-                period: <span class="text-blue-600">${$this.period}</span>)
+                <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>
+                    (ID: <span class="text-blue-600">${$this.siteID}</span>,
+                    date: <span class="text-blue-600">${$this.date}</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>
               </h2>`;
 
               let result = '';
 
-              // Check first item of filteredData object to see if it contains a nested array of data
-              if (Array.isArray(Object.values(filteredData)[0])) {
+              // See if the API has returned an array instead of an object
+              // This test is done on the unfiltered data because the filtering process turns it into an object
+              if (Array.isArray(data)) {
+                  result += $this.tableFromData(data, true);
+
+              // Also check first item of filteredData object to see if it contains a nested array of data
+              } else if (Array.isArray(Object.values(filteredData)[0])) {
                   for (const [date, nestedData] of Object.entries(filteredData)) {
                       console.log(date, nestedData)
                       result += `<h3 class="bg-gray-700 font-bold mt-4 mb-2 rounded px-3 py-1 text-white text-sm w-max">${date}</h3>`;
                   result += $this.tableFromData(filteredData);
               }
 
-              $this.$refs.result.insertAdjacentHTML('afterbegin', requestDetails + result + '<hr class="my-4">')
+              let html = `<div class="API-result" x-data="{ expanded: true }">
+                            ${requestDetails}
+                            <div class="API-result-content" x-show="expanded" x-collapse>${result}</div>
+                          </div>`;
+
+              $this.$refs.result.insertAdjacentHTML('afterbegin', html)
 
           });
       },
               :
               `<th class="${tableClasses}">&nbsp;</th>`; // Only a singular value returned by API
 
-          table += `<tr class="bg-gray-400">`;
+          table += `<tr class="bg-gray-400 sticky" style="top: calc(-1.5rem - 1px)">`;
 
           if (!hideDateColumn) {
               table += `<th class="${tableClasses}">Date</th>`
   }
 </script>
 
+<!-- Alpine Plugins -->
+<script defer src="https://unpkg.com/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
+<!-- Alpine Core -->
 <script src="//unpkg.com/alpinejs" defer></script>
 {{--<script src="https://unpkg.com/json-formatter-js"></script>--}}
 </body>
index 971778e4824e9a0b01f628c846870cf6bc8a917b..e3db6b933d00fd5e46afe6640f9c7fef0bd7ce7e 100644 (file)
 @extends(backpack_view('blank'))
 
+@section('after_styles')
+    <style>
+        .summary {
+            display: flex;
+            flex-wrap: wrap;
+            align-items: center;
+        }
+        .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) {
+            background-color: #f4f4f4;
+        }
+        .summary dt:nth-of-type(even), .summary dd:nth-of-type(even) {
+            background-color: #eaeaea;
+        }
+    </style>
+@endsection
+
+@section('header')
+    <h2 class="my-4">{{ sprintf(__('Statistiques de la publication « %s »'), $fluidbook->name) }}</h2>
+@endsection
+
 @section('content')
-    <h1>{{ __('Statistiques') }}</h1>
 
-{{--    <div class="chart-wrapper" style="position:relative; height: 30vh;">--}}
-{{--        <div id="chart"></div>--}}
-{{--    </div>--}}
+    <dl class="summary">
+        <dt>{{ __('Creation Date') }}</dt>
+        <dd>{{ $fluidbook->created_at->isoFormat('dddd Do MMMM YYYY') }}</dd>
+
+        <dt>{{ __('Total Visits') }}</dt>
+        <dd>{{ $visits->sum('nb_visits') }}</dd>
+
+        <dt>{{ __('Total Page Views') }}</dt>
+        <dd>{{ $pageviews->sum('nb_pageviews') }}</dd>
+
+        {{-- TODO: get extended stats for links, sharing, etc --}}
+
+    </dl>
+
+    @if ($period === 'month')
+        <h2>{{ __('Monthly Details') }}</h2>
+    @elseif ($period === 'day')
+        <h2>{{ __('Daily Details') }}</h2>
+    @endif
+
+    <canvas id="stats_chart"></canvas>
+
+    <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
 
-    @dump($fluidbook, $visits, $pageviews, $searches)
+    {{--@dump($fluidbook, $visits, $pageviews, $searches)--}}
 
 @endsection
 
-{{--@push('after_scripts')--}}
-{{--    --}}{{-- Charting library --}}
-{{--    <script src="https://unpkg.com/chart.js@^2.9.3/dist/Chart.min.js"></script>--}}
-{{--    --}}{{-- Chartisan --}}
-{{--    <script src="https://unpkg.com/@chartisan/chartjs@^2.1.0/dist/chartisan_chartjs.umd.js"></script>--}}
-
-{{--    <script>--}}
-{{--        const bar_offset = 2; // How much to offset each overlapping bar (applied as a -/+ depending on which dataset)--}}
-{{--        const chart = new Chartisan({--}}
-{{--            el: '#chart',--}}
-{{--            data: {!! $chart !!},--}}
-{{--            hooks: new ChartisanHooks()--}}
-{{--                .colors(['#9ec400', '#888'])--}}
-{{--                .tooltip({--}}
-{{--                    mode: 'index',--}}
-{{--                    position: 'nearest',--}}
-{{--                    callbacks: {--}}
-{{--                        title: function(context) {--}}
-{{--                            return chart.options.data.chart.extra.tooltip_labels[context[0].label];--}}
-{{--                        }--}}
-{{--                    },--}}
-{{--                })--}}
-{{--                .datasets([{--}}
-{{--                    barPercentage: 0.9,--}}
-{{--                    // categoryPercentage: 0.8,--}}
-{{--                    // maxBarThickness: 50,--}}
-{{--                }])--}}
-{{--                // Complex options (ref: https://github.com/Chartisan/Chartisan/issues/7#issuecomment-774745067)--}}
-{{--                .custom(function({ data, merge, server }) {--}}
-{{--                    // data ->   Contains the current chart configuration--}}
-{{--                    //           data that will be passed to the chart instance.--}}
-{{--                    // merge ->  Contains a function that can be called to merge--}}
-{{--                    //           two javascript objects and returns its merge.--}}
-{{--                    // server -> Contains the server information in case you need--}}
-{{--                    //           to access the raw information provided by the server.--}}
-{{--                    //           This is mostly used to access the `extra` field.--}}
-
-
-{{--                    //console.log('data', data, 'server', server);--}}
-
-{{--                    let merged = merge(data, {--}}
-{{--                        options: {--}}
-{{--                            maintainAspectRatio: false,--}}
-{{--                            // responsive: true,--}}
-{{--                            scales: {--}}
-{{--                                xAxes: [{ stacked: true }],--}}
-{{--                                yAxes: [{ stacked: false }], // Don't stack y-axis: prevents datasets from adding--}}
-{{--                            },--}}
-{{--                        },--}}
-{{--                        plugins: [{--}}
-{{--                            afterUpdate: function(chart) {--}}
-{{--                                // Create an offset between the bars--}}
-{{--                                // Ref: https://stackoverflow.com/questions/62731182/chart-js-creating-a-barchart-with-overlaying-and-offset-bars--}}
-{{--                                chart.config.data.datasets.forEach(function(dataset, index) {--}}
-{{--                                    // First dataset is moved left (-), second is moved right (+)--}}
-{{--                                    let offset = index % 2 === 0 ? 0 - bar_offset : bar_offset;--}}
-
-{{--                                    dataset._meta[0].data.forEach(function(entry) {--}}
-{{--                                        let model = entry._model;--}}
-{{--                                        model.x += offset;--}}
-{{--                                        model.controlPointNextX = offset;--}}
-{{--                                        model.controlPointPreviousX = offset;--}}
-{{--                                    });--}}
-{{--                                });--}}
-{{--                            }--}}
-{{--                        }]--}}
-{{--                    });--}}
-
-{{--                    //console.log("MERGED!", merged)--}}
-
-{{--                    return merged;--}}
-
-{{--                    // The function must always return the new chart configuration.--}}
-{{--                })--}}
-{{--        })--}}
-{{--    </script>--}}
-
-{{--@endpush--}}
+@push('after_scripts')
+    {{-- 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: [
+                {
+                    label: 'Visits',
+                    backgroundColor: 'hsl(72 100% 38% / 100%)',
+                    // borderColor: 'hsl(72 100% 38%)',
+                    // borderWidth: 1,
+                    data: {!! json_encode($visits->pluck('nb_visits')->toArray()) !!},
+                    order: 1,
+                },
+                {
+                    label: 'Page Views',
+                    backgroundColor: 'hsl(0 0% 53% / 100%)',
+                    // borderColor: 'hsl(0 0% 53%)',
+                    // borderWidth: 1,
+                    data: {!! json_encode($pageviews->pluck('nb_pageviews')->toArray()) !!},
+                    order: 2,
+                },
+            ]
+        };
+
+        //=== Plugins
+        //== Offset Bars Plugin: creates a small offset between stacked bars
+        const offsetBars = {
+            id: 'offsetBars',
+            beforeDatasetsDraw(chart, args, options) {
+
+                // How much to offset each overlapping bars (applied as a -/+ depending on which dataset)
+                const bar_offset_percentage = 5; // % width of each bar
+                let bar_offset_amount = Math.round(chart.getDatasetMeta(0).data[0].width * bar_offset_percentage / 100);
+                bar_offset_amount = Math.max(2, bar_offset_amount); // Make sure offset isn't below 2
+
+                // Create an offset between the stacked bars
+                chart.config.data.datasets.forEach(function(dataset, datasetIndex) {
+                    // The first dataset is moved left (-), second is moved right (+)
+                    let offset = datasetIndex % 2 === 0 ? 0 - bar_offset_amount : bar_offset_amount;
+
+                    // Go through each data point (bar) and apply the horizontal positioning offset
+                    chart.getDatasetMeta(datasetIndex).data.forEach(function(dataPoint, index) {
+                        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]
+        };
+
+        //=== Render Chart
+        const statsChart = new Chart(
+            document.getElementById('stats_chart'),
+            config
+        );
+
+    </script>
+
+@endpush
index dae0f57a4a73c1fc4705ce07e6f56576e6a55ee9..a3b29170bd7d720c96f2aded64c874beaa003740 100644 (file)
@@ -1,5 +1,5 @@
 @if($entry->stats)
-    <a class="btn btn-sm btn-link" href="{{$crud->route}}/stats/{{$entry->id}}_{{$entry->hash}}"
+    <a class="btn btn-sm btn-link" href="{{ route('stats', ['fluidbook_id' => $entry->id, 'hash' => $entry->hash]) }}"
        data-toggle="tooltip"
        title="{{__('Consulter les statistiques')}}"><i class="la la-chart-bar"></i> {{__('Stats')}}
     </a>