use Carbon\CarbonInterface;
use Cubist\Matomo\MatomoUtils;
use Cubist\Matomo\Reporting;
+use Cubist\Util\Data;
use NumberFormatter;
class Stats extends Reporting
{
- public function __construct($id, $date = self::DATE_YESTERDAY, $period = self::PERIOD_DAY, $format = self::FORMAT_JSON)
+ // * 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
+ const SHOW_VISITORS_CUTOFF = 20687;
+
+
+ /**
+ * @var int
+ */
+ protected $fluidbook_id;
+
+ /**
+ * @var string
+ */
+ protected $hash;
+
+ /**
+ * @var FluidbookPublication
+ */
+ protected $fluidbook;
+
+ /**
+ * @var Data
+ */
+ protected $viewData;
+
+
+ /**
+ * @var array
+ */
+ protected $_pagesByPeriod;
+ /**
+ * @var array
+ */
+ protected $_eventsByPeriod;
+ /**
+ * @var array
+ */
+ protected $_eventsByPage;
+
+ /**
+ * @param $fluidbook FluidbookPublication
+ */
+ public function __construct($fluidbook)
{
- $server = $this->getMatomoServer($id);
+ $this->fluidbook = $fluidbook;
+
+ $this->fluidbook_id = intval($this->fluidbook->id);
+ $this->hash = $this->fluidbook->hash;
+
$this->setLanguage(app()->getLocale());
- parent::__construct('https://' . $server . '/', $this->getMatomoToken($this->getMatomoToken($server)), $id, $date, $period);
+ $this->viewData = new Data(['fluidbook_id' => $fluidbook->id, 'hash' => $this->hash, 'title' => $fluidbook->title,'page_count'=>$fluidbook->getPagesNumber()]);
+
+ parent::__construct('https://' . static::getMatomoServer($this->fluidbook->id) . '/', null, $this->fluidbook_id);
}
+ public function getToken(): string
+ {
+ return $this->getMatomoToken(static::getMatomoServer($this->fluidbook->id));
+ }
+
protected function getMatomoTokens()
{
// Each stats server has a different instance of Matamo, so we need to provide different API tokens for each
];
}
+ protected static function getPeriods()
+ {
+ // Translatable list of labels for the available periods
+ return [
+ 'day' => [
+ 'singular' => __('Jour'),
+ 'periodic' => __('Quotidien'),
+ ],
+ 'week' => [
+ 'singular' => __('Semaine'),
+ 'periodic' => __('Hebdomadaire'),
+ ],
+ 'month' => [
+ 'singular' => __('Mois'),
+ 'periodic' => __('Mensuel'),
+ ],
+ 'year' => [
+ 'singular' => __('Année'),
+ 'periodic' => __('Annuel'),
+ ],
+ ];
+ }
+
+ /**
+ * @param $id int
+ * @return string
+ */
public static function getMatomoServer($id): string
{
// Get the appropriate server based on the Fluidbook 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) {
+ $id = intval($id);
+ if ($id < 21210) {
return 'stats3.fluidbook.com';
- } elseif ($fluidbook_id % 2 === 0) {
+ } elseif ($id % 2 === 0) {
return 'stats4.fluidbook.com';
}
return 'stats5.fluidbook.com';
return $tokens[$server] ?? false;
}
- protected static function processData($fluidbook)
+ protected function setDatesAndPeriod($period_override = null)
{
- $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:
// - 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 ? MatomoUtils::parseDate($date) : false;
+ $this->viewData->period_map = self::getPeriods();
+ $this->viewData->dates = $this->getDate() ? MatomoUtils::parseDate($this->getDate()) : 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);
+ $period_override = in_array($period_override, array_keys($this->viewData->period_map)) ? $period_override : null;
+ $this->viewData->period = $period_override ?? $this->determinePeriod($this->viewData->dates);
- $chart_heading = __('Détails :period', ['period' => $period_map[$period]['periodic']]);
- $report_timespan = '';
+ $this->viewData->chart_heading = __('Détails :period', ['period' => $this->viewData->period_map[$this->viewData->period]['periodic']]);
+ $this->viewData->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(), [
+ $this->viewData->mode = 'range';
+ $this->viewData->start_date = $dates['start_date'];
+ $this->viewData->end_date = $dates['end_date'];
+ $date_range = "{$this->viewData->start_date},{$this->viewData->end_date}";
+
+ // 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)
+ $this->viewData->report_timespan = Carbon::parse($this->viewData->start_date)->startOfDay()->diffForHumans(Carbon::parse($this->viewData->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';
+ $this->viewData->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 .= '<span class="heading-subtitle">' . Carbon::parse($start_date)->isoFormat('MMMM YYYY') . '</span>';
+ $this->viewData->start_date = "{$year}-{$month}-01";
+ $date_range = "{$this->viewData->start_date},{$year}-{$month}-{$last_day_of_month}";
+ $this->viewData->end_date = ($month == date('m') && $year == date('Y')) ? date('Y-m-d') : "{$year}-{$month}-{$last_day_of_month}";
+ $this->viewData->chart_heading .= '<span class="heading-subtitle">' . Carbon::parse($this->viewData->start_date)->isoFormat('MMMM YYYY') . '</span>';
} elseif (!empty($dates['start_year'])) { // Year view
- $mode = 'year';
+ $this->viewData->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 .= "<span class='heading-subtitle'>$year</span>";
+ $this->viewData->start_date = "{$year}-01-01";
+ $date_range = "{$this->viewData->start_date},{$year}-12-31"; // Full range of specified year (guarantees a full chart, even if it's early in the current year)
+ $this->viewData->end_date = $year == date('Y') ? date('Y-m-d') : "{$year}-12-31"; // If it's the current year, don't count future dates
+ $this->viewData->chart_heading .= "<span class='heading-subtitle'>$year</span>";
} 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');
+ $this->viewData->mode = 'overview';
+ $this->viewData->start_date = $this->fluidbook->created_at->isoFormat('YYYY-MM-DD');
+ $this->viewData->end_date = now()->isoFormat('YYYY-MM-DD');
+ $date_range = "{$this->viewData->start_date},{$this->viewData->end_date}"; // All data up until today's date
+ $this->viewData->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');
- });
+ $this->viewData->formatted_date_range = self::formatDateRange($this->viewData->start_date, $this->viewData->end_date);
-// 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;
- });
+ //=== Set up Matomo Reporting API
+ $this->setDate($date_range);
+ $this->setPeriod($this->viewData->period);
+ }
-//=== 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 === '/';
+ /**
+ * @return array
+ */
+ public function processData($period_override = null)
+ {
+ $this->setDatesAndPeriod($period_override);
+
+ $this->_eventsByPeriod = $this->_processEventsByPeriod();
+ $this->_eventsByPage = $this->_processEventsByPage();
+
+ $this->viewData->pages = $this->_processPages();
+ $this->viewData->searches = $this->_processSearches();
+ $this->viewData->outlinks = $this->_processOutgoingLinks();
+ $this->viewData->countries = $this->_processCountries();
+
+ //=== 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 = $this->fluidbook_id >= self::SHOW_VISITORS_CUTOFF ? 1 : 0; // Only fetch extra data when it will be used
+ $this->_pagesByPeriod = collect($this->getPageUrls(['expanded' => $expanded_stats]))
+ ->map(function ($item, $date) {
+ return $this->_mapPagesByPeriod($item, $date);
});
-//=== 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);
- }
+ //=== 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
+ $this->viewData->period_details = $this->_pagesByPeriod->filter(fn($value, $key) => !empty($value)); // Remove any empty periods
- $data['raw_date'] = $date; // We still need the raw date for sorting and other formatting purposes
+ $this->viewData->chart_datasets = $this->_processCharts();
- // Formatting of date changes depending on the period
- $formatted_date = $this->formatDateForPeriod($date, $period);
+ // Map of API data to table headings (used to display summaries under the chart)
+ $this->viewData->table_map = $this->_getTableMap();
+ $this->viewData->formatter = NumberFormatter::create($this->getLanguage(), NumberFormatter::PATTERN_DECIMAL);
- // 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'] = "<a href='{$URL}'>{$formatted_date}</a>";
- } else {
- $data['formatted_date'] = $formatted_date;
- }
-
- // Add certain event category data
- $period_events = $eventsByPeriod->get($date);
- $data['nb_zooms'] = data_get($period_events, 'zoom.nb_events', 0);
- $data['nb_shares'] = data_get($period_events, 'share.nb_events', 0);
- $data['nb_links'] = data_get($period_events, 'link.nb_events', 0); // Used instead of standard outlinks count
-
- // Download and Print events are stored differently and can't be changed now
- // (see https://redmine.cubedesigners.com/issues/5431#note-2)
- // If 'menu' event exists, collect and key it by label so we can use data_get() for cleaner access
- $menu_events = $period_events->has('menu') ? collect($period_events['menu']['subtable'])->keyBy('label') : [];
- $data['nb_downloads'] = data_get($menu_events, 'download.nb_events', 0);
- $data['nb_prints'] = data_get($menu_events, 'print.nb_events', 0);
-
- return $data;
- });
+ return $this->viewData->getRawData();
+ }
-//=== 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 = [
+ protected function _processCharts()
+ {
+ //=== CHART PREPARATION
+ // Format dates for display as labels on the x-axis and in the tooltips / tables
+ $this->viewData->tooltip_labels = $this->_pagesByPeriod->keys()->mapWithKeys(
+ function ($date, $index) {
+ $short_label = $this->formatDateForXAxis($date, $this->getPeriod(), $this->viewData->start_date, $this->viewData->end_date);
+ $full_label = $this->formatDateForPeriod($date, $this->getPeriod());
+ return [$short_label => $full_label];
+ })
+ ->toArray();
+
+ $res = [
[
'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(),
+ 'data' => $this->_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(),
+ 'data' => $this->_pagesByPeriod->pluck('nb_hits')->toArray(),
'order' => 2,
'bar_offset' => 5, // Positive offset shifts bar to right
],
];
- if ($fluidbook_id >= $new_stats_cutoff) {
+ if ($this->fluidbook_id >= self::SHOW_VISITORS_CUTOFF) {
// Insert the unique visitors dataset at the beginning of the array
- $chart_datasets = array_merge([
+ $res = array_merge([
[
'label' => __('Visiteurs uniques'),
'backgroundColor' => 'hsl(19 100% 48% / 100%)',
- 'data' => $pagesByPeriod->pluck('nb_uniq_visitors')->toArray(),
+ 'data' => $this->_pagesByPeriod->pluck('nb_uniq_visitors')->toArray(),
'order' => 1,
'bar_offset' => -8, // Negative offset shifts bar to left
]
- ], $chart_datasets);
+ ], $res);
// 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
+ $res[1]['order'] = 2;
+ $res[1]['bar_offset'] = 0; // This will be the middle bar, so no offset
- $chart_datasets[2]['order'] = 3;
- $chart_datasets[2]['bar_offset'] = 8;
+ $res[2]['order'] = 3;
+ $res[2]['bar_offset'] = 8;
}
- $chart_datasets = json_encode($chart_datasets, JSON_UNESCAPED_SLASHES);
+ return json_encode($res, JSON_UNESCAPED_SLASHES);
+
+ }
+
-// Map of API data to table headings (used to display summaries under the chart)
- $table_map = [
+ protected function _getTableMap()
+ {
+ $res = [
// Main summary table
'summary' => [
- 'formatted_date' => $period_map[$period]['singular'],
+ 'formatted_date' => self::getPeriods()[$this->getPeriod()]['singular'],
'nb_uniq_visitors' => __('Visiteurs uniques'),
'nb_visits' => __('Visites'),
'nb_hits' => __('Pages vues'),
],
];
-// 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']);
+ // Older Fluidbooks can't show unique visitors (see notes above)
+ if ($this->fluidbook_id < self::SHOW_VISITORS_CUTOFF) {
+ unset($res['summary']['nb_uniq_visitors']);
+ unset($res['per-page']['nb_uniq_visitors']);
}
+ return $res;
+
+ }
+
+ /**
+ * @return array
+ */
+ protected function _processEventsByPage()
+ {
+ // 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 $this->_eventsByPeriod data, it's cleaner to use the API
+ return collect($this->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;
+ });
+ }
+
- $formatter = NumberFormatter::create($locale, NumberFormatter::DEFAULT_STYLE);
+ protected function _processEventsByPeriod()
+ {
+ //=== 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.
+ return collect($this->getEventsByCategory(['expanded' => 1]))->map(function ($item, $key) {
+ return collect($item)->keyBy('label');
+ });
}
+ protected function _mapPagesByPeriod($item, $date)
+ {
+ //use ($period, $this->fluidbook_id, $hash, $this->_eventsByPeriod, self::self::SHOW_VISITORS_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 ($this->fluidbook_id >= self::SHOW_VISITORS_CUTOFF) {
+ $unique_visitors_key = $this->getPeriod() === '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, $this->getPeriod());
+
+ // When segregated by month or year, the periods become links
+ if (in_array($this->getPeriod(), ['month', 'year'])) {
+ // Generate URL using the named route
+ $URL = route('stats', ['fluidbook_id' => $this->fluidbook_id, 'hash' => $this->hash, 'date' => $date]);
+ $data['formatted_date'] = "<a href='{$URL}'>{$formatted_date}</a>";
+ } else {
+ $data['formatted_date'] = $formatted_date;
+ }
+
+ // Add certain event category data
+ $period_events = $this->_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;
+ }
+ protected function _processOutgoingLinks()
+ {
+ //=== 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)
+ return collect($this->_eventsByPage['link']['subtable'] ?? [])->sortByDesc('nb_events');
+ }
- // 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)
+ protected function _processCountries()
{
- $date_matches = MatomoUtils::parseDate($date);
+ //=== 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:
+ $res = [];
+ $stats_server = $this->getServerUrl();
+ $flags_base_URL = $stats_server . 'plugins/Morpheus/icons/dist/flags/';
- // ...
+ foreach ($this->getCountries(['period' => 'range']) as $country) {
+ $country_code = strtolower($country['code']);
- // 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.
+ if (isset($res[$country_code])) {
+ $res[$country_code]['nb_visits'] += $country['nb_visits'];
+ } else {
+ $res[$country_code]['label'] = $country['label'];
+ $res[$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...
+ $res[$country_code]['flag'] = $flags_base_URL . $country_code . '.png';
+ }
+ }
+ return collect($res)->sortByDesc('nb_visits');
+ }
+ protected function _processSearches()
+ {
+ //=== 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)
+ return collect($this->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
}
- // Figure out the best period to use for the stats according to the date range
- public function determinePeriod($dates, $fluidbook)
+ /**
+ * @return array
+ */
+ protected function _processPages()
{
+ //=== 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($this->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
+ $res = [];
+ for ($page_number = 1; $page_number <= $this->fluidbook->getPagesNumber(); $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 ($this->fluidbook->isOnePage() || $page_number === 1 || ($page_number === $this->fluidbook->getPagesNumber() && $this->fluidbook->getPagesNumber() % 2 === 0)) {
+ $page_group = $page_number;
+
+ $res[$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($this->_eventsByPage, "zoom.subtable.$page_number.nb_events", 0),
+ 'nb_bookmarks' => data_get($this->_eventsByPage, "bookmark.subtable.$page_number.nb_events", 0),
+ 'nb_shares' => data_get($this->_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 (!$this->fluidbook->isOnePage() && $page_number === 1) {
+ $res[$page_group]['nb_visits'] = $pageUrls["/page/0"]['nb_visits'] ?? 0;
+ $res[$page_group]['nb_uniq_visitors'] = $pageUrls["/page/0"]['sum_daily_nb_uniq_visitors'] ?? 0;
+ $res[$page_group]['nb_pageviews'] = $pageUrls["/page/0"]['nb_hits'] ?? 0;
+ $res[$page_group]['nb_zooms'] = data_get($this->_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;
+
+ $res[$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($this->_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($this->_eventsByPage, "bookmark.subtable.$page_number.nb_events", 0)
+ + data_get($this->_eventsByPage, "bookmark.subtable.$following_page.nb_events", 0),
+ 'nb_shares' => data_get($this->_eventsByPage, "share.subtable.$page_number.nb_events", 0)
+ + data_get($this->_eventsByPage, "share.subtable.$following_page.nb_events", 0),
+ ];
+ }
+ }
+ return $res;
+ }
- // 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...
+ // Figure out the best period to use for the stats according to the date range
+ public function determinePeriod($dates)
+ {
// 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['start_date'] = $this->fluidbook->created_at->isoFormat('YYYY-MM-DD');
$dates['end_date'] = now()->isoFormat('YYYY-MM-DD');
}
+ return MatomoUtils::guessBestPeriod($dates);
+ }
+
+
+ // Format a date range for display in the interface
+ public static function formatDateRange($start_date, $end_date): string
+ {
+
+ $start = Carbon::parse($start_date);
+ $end = Carbon::parse($end_date);
+ $crosses_month_boundary = $end->isoFormat('YYYY-MM') > $start->isoFormat('YYYY-MM');
+ $crosses_year_boundary = $end->isoFormat('YYYY') > $start->isoFormat('YYYY');
+
+ // Default formatting for start and end dates according to Carbon isoFormat() function
+ $start_format = 'Do'; // Day with ordinal
+ $end_format = 'Do MMMM, YYYY';
+ $separator = $crosses_year_boundary || $crosses_month_boundary ? ' — ' : '—';
+
+ if ($crosses_year_boundary) {
+ // When a date range crosses a year boundary, we need to show the full dates
+ $start_format = 'MMMM Do, YYYY';
+ $end_format = 'MMMM Do, YYYY';
+ } elseif ($crosses_month_boundary) {
+ // When crossing a month boundary, show both month names
+ $start_format = 'MMMM Do';
+ $end_format = 'MMMM Do, YYYY';
+ }
+
+ return $start->isoFormat($start_format) . $separator . $end->isoFormat($end_format);
+ }
+
+
+ // Format dates depending on the segregation period (used for tooltips and stats detail tables)
+ public static function formatDateForPeriod($date, $period): string
+ {
+
+ // Weeks are a special case because they contain two dates
+ if ($period === 'week') {
+ $weeks = explode(',', $date);
+ $week_start = Carbon::parse($weeks[0]);
+ $week_end = Carbon::parse($weeks[1]);
+ $crosses_month_boundary = $week_end->isoFormat('YYYY-MM') > $week_start->isoFormat('YYYY-MM');
+ $crosses_year_boundary = $week_end->isoFormat('YYYY') > $week_start->isoFormat('YYYY');
+ 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, YYYY');
+ } else {
+ $week_formatted = $week_start->isoFormat('Do') . '—' . $week_end->isoFormat('Do MMMM, YYYY');
+ }
+ }
+
+ return match ($period) {
+ 'day' => Carbon::parse($date)->isoFormat('dddd, Do MMMM YYYY'),
+ 'week' => $week_formatted,
+ 'month' => Carbon::parse($date)->isoFormat('MMMM YYYY'),
+ default => $date,
+ };
+ }
+
+ // Dates displayed in the x-axis of chart need different formatting
+ public static function formatDateForXAxis($date, $period, $start_date, $end_date): string
+ {
+
+ // The labels on the x-axis should be as concise as possible but when the date range crosses
+ // a boundary for the larger period (eg. period = day but the range crosses more than one month),
+ // the formatting needs to be different so that labels are not ambiguous.
+ $start = Carbon::parse($start_date);
+ $end = Carbon::parse($end_date);
+ $crosses_month_boundary = $end->isoFormat('YYYY-MM') > $start->isoFormat('YYYY-MM');
+ $crosses_year_boundary = $end->isoFormat('YYYY') > $start->isoFormat('YYYY');
+
+
+ // Week period dates need different handling because they contain two comma-separated dates
+ if ($period === 'week') {
+ $weeks = explode(',', $date);
+ $week_start = Carbon::parse($weeks[0]);
+ $week_end = Carbon::parse($weeks[1]);
+ $crosses_month_boundary = $week_end->isoFormat('YYYY-MM') > $week_start->isoFormat('YYYY-MM');
+ $crosses_year_boundary = $week_end->isoFormat('YYYY') > $week_start->isoFormat('YYYY');
+ if ($crosses_year_boundary) {
+ $week_formatted = $week_start->isoFormat('D MMM YYYY') . ' — ' . $week_end->isoFormat('D MMM YYYY');
+ } elseif ($crosses_month_boundary) {
+ $week_formatted = $week_start->isoFormat('D MMM') . ' — ' . $week_end->isoFormat('D MMM');
+ } else {
+ $week_formatted = $week_start->isoFormat('D') . '—' . $week_end->isoFormat('D MMM');
+ }
+ } elseif ($period === 'year') {
+ // Years also need special handling because if we try to use Carbon::parse()
+ // with a 4-digit year, it won't give the expected result...
+ // There's nothing to do here, just let it be returned unprocessed by the default case.
+ } else {
+ $date = Carbon::parse($date);
+ }
- return MatomoUtils::getBestPeriod($dates);
+ return match ($period) {
+ 'day' => $crosses_year_boundary ? $date->isoFormat('DD MMM YYYY')
+ : ($crosses_month_boundary ? $date->isoFormat('DD MMM') : $date->isoFormat('DD')),
+ 'week' => $week_formatted,
+ 'month' => $crosses_year_boundary ? $date->isoFormat('MMM YYYY') : $date->isoFormat('MMM'),
+ default => $date,
+ };
}
}
$locale = app()->getLocale();
$base_URL = route('stats', compact('fluidbook_id', 'hash')); // Used by date range picker to update report URL
- $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 .= '<span class="heading-subtitle">' . Carbon::parse($start_date)->isoFormat('MMMM YYYY') . '</span>';
-
- } 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 .= "<span class='heading-subtitle'>$year</span>";
-
- } 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=new Stats($fluidbook->id);
-
-
- // * 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'] = "<a href='{$URL}'>{$formatted_date}</a>";
- } else {
- $data['formatted_date'] = $formatted_date;
- }
-
- // Add certain event category data
- $period_events = $eventsByPeriod->get($date);
- $data['nb_zooms'] = data_get($period_events, 'zoom.nb_events', 0);
- $data['nb_shares'] = data_get($period_events, 'share.nb_events', 0);
- $data['nb_links'] = data_get($period_events, 'link.nb_events', 0); // Used instead of standard outlinks count
-
- // Download and Print events are stored differently and can't be changed now
- // (see https://redmine.cubedesigners.com/issues/5431#note-2)
- // If 'menu' event exists, collect and key it by label so we can use data_get() for cleaner access
- $menu_events = $period_events->has('menu') ? collect($period_events['menu']['subtable'])->keyBy('label') : [];
- $data['nb_downloads'] = data_get($menu_events, 'download.nb_events', 0);
- $data['nb_prints'] = data_get($menu_events, 'print.nb_events', 0);
-
- return $data;
- });
-
- //=== 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']);
- }
+ $data = compact(
+ 'fluidbook_id',
+ 'hash',
+ 'date',
+ 'fluidbook',
+ 'locale',
+ 'base_URL',
+ );
- $formatter = NumberFormatter::create($locale, NumberFormatter::DEFAULT_STYLE);
+ $stats = new Stats($fluidbook, $date);
+ $data = array_merge($data, $stats->processData($period_override));
- return view('fluidbook_stats.summary',
- compact(
- 'fluidbook_id',
- 'hash',
- 'date',
- 'fluidbook',
- 'fluidbook_settings',
- 'start_date',
- 'end_date',
- 'report_timespan',
- 'page_count',
- 'tooltip_labels',
- 'formatted_date_range',
- 'chart_heading',
- 'chart_datasets',
- 'searches',
- 'outlinks',
- 'countries',
- 'mode',
- 'period',
- 'dates',
- 'period_details',
- 'period_map',
- 'table_map',
- 'pages',
- 'formatter',
- 'locale',
- 'base_URL',
- )
- );
+ return view('fluidbook_stats.summary', $data);
}
return view('fluidbook_stats.API', compact('matomo_tokens', 'id'));
}
- // Format a date range for display in the interface
- protected function formatDateRange($start_date, $end_date): string
- {
-
- $start = Carbon::parse($start_date);
- $end = Carbon::parse($end_date);
- $crosses_month_boundary = $end->isoFormat('YYYY-MM') > $start->isoFormat('YYYY-MM');
- $crosses_year_boundary = $end->isoFormat('YYYY') > $start->isoFormat('YYYY');
-
- // Default formatting for start and end dates according to Carbon isoFormat() function
- $start_format = 'Do'; // Day with ordinal
- $end_format = 'Do MMMM, YYYY';
- $separator = $crosses_year_boundary || $crosses_month_boundary ? ' — ' : '—';
-
- if ($crosses_year_boundary) {
- // When a date range crosses a year boundary, we need to show the full dates
- $start_format = 'MMMM Do, YYYY';
- $end_format = 'MMMM Do, YYYY';
- } elseif ($crosses_month_boundary) {
- // When crossing a month boundary, show both month names
- $start_format = 'MMMM Do';
- $end_format = 'MMMM Do, YYYY';
- }
-
- return $start->isoFormat($start_format) . $separator . $end->isoFormat($end_format);
- }
-
-
- // Format dates depending on the segregation period (used for tooltips and stats detail tables)
- protected function formatDateForPeriod($date, $period): string
- {
-
- // Weeks are a special case because they contain two dates
- if ($period === 'week') {
- $weeks = explode(',', $date);
- $week_start = Carbon::parse($weeks[0]);
- $week_end = Carbon::parse($weeks[1]);
- $crosses_month_boundary = $week_end->isoFormat('YYYY-MM') > $week_start->isoFormat('YYYY-MM');
- $crosses_year_boundary = $week_end->isoFormat('YYYY') > $week_start->isoFormat('YYYY');
- 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, YYYY');
- } else {
- $week_formatted = $week_start->isoFormat('Do') . '—' . $week_end->isoFormat('Do MMMM, YYYY');
- }
- }
-
- return match ($period) {
- 'day' => Carbon::parse($date)->isoFormat('dddd, Do MMMM YYYY'),
- 'week' => $week_formatted,
- 'month' => Carbon::parse($date)->isoFormat('MMMM YYYY'),
- default => $date,
- };
- }
-
- // Dates displayed in the x-axis of chart need different formatting
- protected function formatDateForXAxis($date, $period, $start_date, $end_date): string
- {
-
- // The labels on the x-axis should be as concise as possible but when the date range crosses
- // a boundary for the larger period (eg. period = day but the range crosses more than one month),
- // the formatting needs to be different so that labels are not ambiguous.
- $start = Carbon::parse($start_date);
- $end = Carbon::parse($end_date);
- $crosses_month_boundary = $end->isoFormat('YYYY-MM') > $start->isoFormat('YYYY-MM');
- $crosses_year_boundary = $end->isoFormat('YYYY') > $start->isoFormat('YYYY');
-
- // Week period dates need different handling because they contain two comma-separated dates
- if ($period === 'week') {
- $weeks = explode(',', $date);
- $week_start = Carbon::parse($weeks[0]);
- $week_end = Carbon::parse($weeks[1]);
- $crosses_month_boundary = $week_end->isoFormat('YYYY-MM') > $week_start->isoFormat('YYYY-MM');
- $crosses_year_boundary = $week_end->isoFormat('YYYY') > $week_start->isoFormat('YYYY');
- if ($crosses_year_boundary) {
- $week_formatted = $week_start->isoFormat('D MMM YYYY') . ' — ' . $week_end->isoFormat('D MMM YYYY');
- } elseif ($crosses_month_boundary) {
- $week_formatted = $week_start->isoFormat('D MMM') . ' — ' . $week_end->isoFormat('D MMM');
- } else {
- $week_formatted = $week_start->isoFormat('D') . '—' . $week_end->isoFormat('D MMM');
- }
- } elseif ($period === 'year') {
- // Years also need special handling because if we try to use Carbon::parse()
- // with a 4-digit year, it won't give the expected result...
- // There's nothing to do here, just let it be returned unprocessed by the default case.
- } else {
- $date = Carbon::parse($date);
- }
-
- return match ($period) {
- 'day' => $crosses_year_boundary ? $date->isoFormat('DD MMM YYYY')
- : ($crosses_month_boundary ? $date->isoFormat('DD MMM') : $date->isoFormat('DD')),
- 'week' => $week_formatted,
- 'month' => $crosses_year_boundary ? $date->isoFormat('MMM YYYY') : $date->isoFormat('MMM'),
- default => $date,
- };
- }
-
}