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