From: Vincent Vanwaelscappel Date: Wed, 24 May 2023 14:06:31 +0000 (+0200) Subject: wait #5943 @0.75 X-Git-Url: http://git.cubedesigners.com/?a=commitdiff_plain;h=f2f9906fd7f3b63d31b813d5d151a7975b6c8a1d;p=fluidbook-toolbox.git wait #5943 @0.75 --- diff --git a/app/Fluidbook/Stats.php b/app/Fluidbook/Stats.php new file mode 100644 index 000000000..75569700a --- /dev/null +++ b/app/Fluidbook/Stats.php @@ -0,0 +1,551 @@ +getMatomoServer($id); + $this->setLanguage(app()->getLocale()); + parent::__construct('https://' . $server . '/', $this->getMatomoToken($this->getMatomoToken($server)), $id, $date, $period); + } + + + 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 + // it is simpler to keep it here. These are also stored in the shared Bitwarden entry for Matomo. + return [ + 'stats3.fluidbook.com' => '9df722a0bd30878ddc4d737352427502', + 'stats4.fluidbook.com' => '3ffdbe052ae625f065573df9fa9515df', + 'stats5.fluidbook.com' => '85e9cc307b6e5083249949e9472a80b8', + 'stats6.fluidbook.com' => '16f4c1d77cdc4792b807718388db96a0', + ]; + } + + public static function getMatomoServer($id): string + { + // 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 + // ID >= 21211 (odd numbers) = stats5.fluidbook.com + $fluidbook_id = intval($id); + + if ($fluidbook_id < 21210) { + return 'stats3.fluidbook.com'; + } elseif ($fluidbook_id % 2 === 0) { + return 'stats4.fluidbook.com'; + } + return 'stats5.fluidbook.com'; + } + + protected function getMatomoToken($server): bool|string + { + $tokens = $this->getMatomoTokens(); + return $tokens[$server] ?? false; + } + + protected static function processData($fluidbook) + { + $fluidbook_settings = json_decode($fluidbook->settings); + + // 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") + // We can then specify the granularity of stats by specifying the period: + // - range = aggregated summary of stats for the specified date range + // - year = summary of stats broken down by year(s) + // - month = summary of stats broken down by month(s) + // - day = summary of stats broken down by day(s) + + // Translatable list of labels for the available periods + $period_map = [ + 'day' => [ + 'singular' => __('Jour'), + 'periodic' => __('Quotidien'), + ], + 'week' => [ + 'singular' => __('Semaine'), + 'periodic' => __('Hebdomadaire'), + ], + 'month' => [ + 'singular' => __('Mois'), + 'periodic' => __('Mensuel'), + ], + 'year' => [ + 'singular' => __('Année'), + 'periodic' => __('Annuel'), + ], + ]; + + $dates = $date ? $this->parseDate($date) : false; + + // Make sure that we receive a valid period from the URL + $period_override = in_array($period_override, array_keys($period_map)) ? $period_override : null; + $period = $period_override ?? $this->determinePeriod($dates, $fluidbook); + + $chart_heading = __('Détails :period', ['period' => $period_map[$period]['periodic']]); + $report_timespan = ''; + + // Which mode are we in? + if (isset($dates['start_date']) && isset($dates['end_date'])) { // Custom date range + $mode = 'range'; + $start_date = $dates['start_date']; + $end_date = $dates['end_date']; + $date_range = "{$start_date},{$end_date}"; +// $formatted_date_range = Carbon::parse($start_date)->isoFormat('MMMM Do, YYYY') . ' — ' . +// Carbon::parse($end_date)->isoFormat('MMMM Do, YYYY'); + +// 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($start_date)->startOfDay()->diffForHumans(Carbon::parse($end_date)->addDay(), [ + 'syntax' => CarbonInterface::DIFF_ABSOLUTE, // Absolute means without "ago" added + 'parts' => 3, // How many levels of detail to go into (ie. years, months, days, hours, etc) + 'join' => true, // Join string with natural language separators for the locale + ]); + + + } elseif (!empty($dates['start_year']) && !empty($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); + $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}"; + $chart_heading .= '' . Carbon::parse($start_date)->isoFormat('MMMM YYYY') . ''; + + } elseif (!empty($dates['start_year'])) { // Year view + $mode = 'year'; + $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 + $chart_heading .= "$year"; + + } else { // No valid dates specified, display the full data set + $mode = 'overview'; + $start_date = $fluidbook->created_at->isoFormat('YYYY-MM-DD'); + $end_date = now()->isoFormat('YYYY-MM-DD'); + $date_range = "{$start_date},{$end_date}"; // All data up until today's date + $chart_heading = __('Aperçu'); + } + + $formatted_date_range = $this->formatDateRange($start_date, $end_date); + +//=== Set up Matomo Reporting API + $report = $this->getReporting($fluidbook_id); + $report->setDate($date_range); + $report->setPeriod($period); + $report->setLanguage($locale); // Matomo will return translated data when relevant (eg. country names) + +// * 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. + +// * Note about unique visitors: +// 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). +// 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_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_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); + + // 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_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), + ]; + } + } + +//=== 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'); + + +//=== 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 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 + $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 + } + + // 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']; + + //== 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 + + // Formatting of date changes depending on the period + $formatted_date = $this->formatDateForPeriod($date, $period); + + // When segregated by month or year, the periods become links + if (in_array($period, ['month', 'year'])) { + // Generate URL using the named route + $URL = route('stats', compact('fluidbook_id', 'hash', 'date')); + $data['formatted_date'] = "{$formatted_date}"; + } 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; + }); + +//=== 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 and in the tooltips / tables + $tooltip_labels = $pagesByPeriod->keys()->mapWithKeys(function ($date, $index) use ($period, $start_date, $end_date) { + $short_label = $this->formatDateForXAxis($date, $period, $start_date, $end_date); + $full_label = $this->formatDateForPeriod($date, $period); + return [$short_label => $full_label]; + })->toArray(); + + $chart_datasets = [ + [ + 'label' => __('Visites'), + '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' => __('Pages vues'), + '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' => __('Visiteurs uniques'), + '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]['singular'], + 'nb_uniq_visitors' => __('Visiteurs uniques'), + 'nb_visits' => __('Visites'), + 'nb_hits' => __('Pages vues'), + 'nb_links' => __('Liens sortants'), + 'nb_downloads' => __('Téléchargements'), + 'nb_prints' => __('Impressions'), + 'nb_zooms' => __('Zooms'), + ], + // Per-page detail table + 'per-page' => [ + 'page_group' => __('Pages'), + 'nb_uniq_visitors' => __('Visites uniques'), + 'nb_visits' => __('Visites'), + 'nb_pageviews' => __('Vues'), + 'nb_zooms' => __('Zooms'), + 'nb_bookmarks' => __('Pages marquées'), + 'nb_shares' => __('Partages'), + ], + ]; + +// 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); + } + + + private function parseDate($date) + { + // Match possible date strings: + // - YYYY + // - YYYY-MM + // - YYYY-MM-DD + // - YYYY-MM-DD,YYYY-MM-DD + // https://regex101.com/r/BLrqm0/1 + $regex = '/^(?(?2\d{3})-?(?0[1-9]|1[012])?-?(?0[1-9]|[12][0-9]|3[01])?),?(?2\d{3}-(?>0[1-9]|1[012])-(?>0[1-9]|[12][0-9]|3[01]))?/'; + + preg_match($regex, $date, $date_matches); + + extract($date_matches); // Just for easier access to match variables + + // Bail out on nonsensical dates + if (isset($start_date) && isset($end_date) && ($start_date > $end_date)) { + return false; + } + + return $date_matches; + } + + // Since the report date can be in different formats (ie. YYYY, YYYY-MM or a full range) + // and this format determines the report mode (range, month or year), we have to figure out + // the mode and full starting and ending dates, as necessary + public function getReportSettings($date, $fluidbook) + { + $date_matches = $this->parseDate($date); + + // ... + + // TODO: centralise all mode + start / end date logic here... then in the main report function, we can use the returned mode in a switch() statement to clean things up a lot. + + + } + + // Figure out the best period to use for the stats according to the date range + public function determinePeriod($dates, $fluidbook) + { + + // TODO: refactor this into getReportSettings() so that this function can simply take the determined start and end dates, calculate the number of days and give an answer based on that. No extra logic to try to figure out which mode it is... + + // If no valid date range is given, use the Fluidbook's lifetime + if (empty($dates) || !is_array($dates)) { + $dates['start_date'] = $fluidbook->created_at->isoFormat('YYYY-MM-DD'); + $dates['end_date'] = now()->isoFormat('YYYY-MM-DD'); + } + + // Extract array keys as variables for easier access: + // $start_date, $start_year, $start_month, $start_day, $end_date + extract($dates); + + // At this point, if the end_date still isn't set, it must either be a YYYY or YYYY-MM that was passed in. + // Therefore, we can also assume that $start_year will be set. All that's left to do is set appropriate + // start and end dates so that we can determine the best period based on the overall length of the range. + if (!isset($end_date)) { + if (isset($start_month) && !empty($start_month)) { // Month mode + $start_date = "{$start_year}-{$start_month}-01"; + $end_date = Carbon::parse($start_date)->endOfMonth()->isoFormat('YYYY-MM-DD'); + } else { // Year mode + $start_date = "{$start_year}-01-01"; + $end_date = Carbon::parse($start_date)->endOfYear()->isoFormat('YYYY-MM-DD'); + } + } + + // Count <= 60 days: period = day + // Count 61–180 days: period = week + // Count 180+ days: period = month + // Count 2+ years: period = year + $day_count = Carbon::parse($start_date)->diffInDays($end_date); + + if ($day_count <= 60) { + return 'day'; + } elseif ($day_count <= 180) { + return 'week'; + } elseif ($day_count <= 730) { + return 'month'; + } + + return 'year'; + } +} diff --git a/app/Http/Controllers/Admin/Operations/FluidbookPublication/StatsOperation.php b/app/Http/Controllers/Admin/Operations/FluidbookPublication/StatsOperation.php index 5ae8f5c60..04b971966 100644 --- a/app/Http/Controllers/Admin/Operations/FluidbookPublication/StatsOperation.php +++ b/app/Http/Controllers/Admin/Operations/FluidbookPublication/StatsOperation.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Admin\Operations\FluidbookPublication; +use App\Fluidbook\Stats; use App\Http\Middleware\CheckIfAdmin; use App\Models\FluidbookPublication; use Carbon\Carbon; @@ -15,6 +16,9 @@ trait StatsOperation { protected function setupStatsRoutes($segment, $routeName, $controller) { + Route::get($segment . '/stats/{fluidbook_id}_{hash}/matomo', $controller . '@redirectMatomo') + ->withoutMiddleware([CheckIfAdmin::class])->name('statsmatomo'); // Named route is used to generate URLs more consistently using route helper + // Main route is only secured by hash (security by obscurity) Route::get($segment . '/stats/{fluidbook_id}_{hash}/{date?}/{period_override?}', $controller . '@statsLoader') ->withoutMiddleware([CheckIfAdmin::class]) @@ -39,6 +43,11 @@ trait StatsOperation $this->crud->addButtonFromView('line', 'stats', 'fluidbook_publication.stats', 'end'); } + protected function redirectMatomo($fluidbook_id) + { + $url = 'https://' . Stats::getMatomoServer($fluidbook_id) . '/index.php?module=CoreHome&action=index&idSite=' . $fluidbook_id . '&period=day&date=yesterday'; + return redirect($url); + } protected function getMatomoTokens() { diff --git a/resources/views/fluidbook_publication/link_editor_icons.blade.php b/resources/views/fluidbook_publication/link_editor_icons.blade.php index 1ae82cd05..ded913075 100644 --- a/resources/views/fluidbook_publication/link_editor_icons.blade.php +++ b/resources/views/fluidbook_publication/link_editor_icons.blade.php @@ -1,5 +1,5 @@ {{-- __('!! Editeur de liens') --}} - + @push('linkeditor_scripts')