From da884d05474f0ae49b062703aed63079416fa380 Mon Sep 17 00:00:00 2001 From: Stephen Cameron Date: Wed, 21 Sep 2022 18:15:04 +0200 Subject: [PATCH] Wait #5316 @30 --- .../FluidbookPublication/StatsOperation.php | 445 +++++- composer.json | 1 + .../daterangepicker/daterangepicker.css | 502 +++++++ .../daterangepicker/daterangepicker.js | 1207 +++++++++++++++++ public/packages/sorttable/sorttable.js | 519 +++++++ resources/views/fluidbook_stats/API.blade.php | 18 +- .../views/fluidbook_stats/summary.blade.php | 374 ++++- 7 files changed, 2953 insertions(+), 113 deletions(-) create mode 100755 public/packages/daterangepicker/daterangepicker.css create mode 100755 public/packages/daterangepicker/daterangepicker.js create mode 100644 public/packages/sorttable/sorttable.js diff --git a/app/Http/Controllers/Admin/Operations/FluidbookPublication/StatsOperation.php b/app/Http/Controllers/Admin/Operations/FluidbookPublication/StatsOperation.php index 7c68a0aaf..07ddcfb80 100644 --- a/app/Http/Controllers/Admin/Operations/FluidbookPublication/StatsOperation.php +++ b/app/Http/Controllers/Admin/Operations/FluidbookPublication/StatsOperation.php @@ -5,18 +5,26 @@ namespace App\Http\Controllers\Admin\Operations\FluidbookPublication; 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() @@ -25,7 +33,7 @@ trait StatsOperation } - 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 @@ -37,9 +45,8 @@ trait StatsOperation ]; } - 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 @@ -49,22 +56,27 @@ trait StatsOperation 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 @@ -75,23 +87,32 @@ trait StatsOperation 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") @@ -104,107 +125,389 @@ trait StatsOperation // 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') . + '' . Carbon::parse($start_date)->isoFormat('MMMM YYYY') . ''; + $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') . "$year"; } 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'] = "{$formatted_date}"; + } else { + $data['formatted_date'] = $formatted_date; + } + + // Add certain event category data + $period_events = $eventsByPeriod->get($date); + $data['nb_zooms'] = data_get($period_events, 'zoom.nb_events', 0); + $data['nb_shares'] = data_get($period_events, 'share.nb_events', 0); + $data['nb_links'] = data_get($period_events, 'link.nb_events', 0); // Used instead of standard outlinks count + + // Download and Print events are stored differently and can't be changed now + // (see https://redmine.cubedesigners.com/issues/5431#note-2) + // If 'menu' event exists, collect and key it by label so we can use data_get() for cleaner access + $menu_events = $period_events->has('menu') ? collect($period_events['menu']['subtable'])->keyBy('label') : []; + $data['nb_downloads'] = data_get($menu_events, 'download.nb_events', 0); + $data['nb_prints'] = data_get($menu_events, 'print.nb_events', 0); + + return $data; + }); + + //=== 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, + }; + } + } diff --git a/composer.json b/composer.json index c63001ddd..2856bc2e4 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "php": ">=8.1", "ext-calendar": "*", "ext-dom": "*", + "ext-intl": "*", "ext-json": "*", "ext-libxml": "*", "ext-simplexml": "*", diff --git a/public/packages/daterangepicker/daterangepicker.css b/public/packages/daterangepicker/daterangepicker.css new file mode 100755 index 000000000..3ca56fc5f --- /dev/null +++ b/public/packages/daterangepicker/daterangepicker.css @@ -0,0 +1,502 @@ +/*! + * 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; +} diff --git a/public/packages/daterangepicker/daterangepicker.js b/public/packages/daterangepicker/daterangepicker.js new file mode 100755 index 000000000..721791fda --- /dev/null +++ b/public/packages/daterangepicker/daterangepicker.js @@ -0,0 +1,1207 @@ +/*! + * 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 + "
" + (months.join(", ")) + "" + } + ]; + } + }; + + CalendarView.prototype.formatDateTemplate = function(date) { + return { + nodes: $("
" + (this.formatDate(date)) + "
").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 = $("
").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 = '
'; + + $.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); diff --git a/public/packages/sorttable/sorttable.js b/public/packages/sorttable/sorttable.js new file mode 100644 index 000000000..acc2ac074 --- /dev/null +++ b/public/packages/sorttable/sorttable.js @@ -0,0 +1,519 @@ +/*== Original version with customisations by Cubedesigners ==*/ +/** + * - CSS-based sort arrows + * - Custom sort value specified using data-sort-value attribute on the cell + * - Allow override of default sort direction using data-sort-direction attribute on header + * - 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 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; i5' : ' ▴'; + // 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 ? ' 6' : ' ▾'; + // 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 ? ' 6' : ' ▾'; + // 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 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 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=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] 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(" + + + + + {{--============================================================================================================--}} + {{-- Charting library --}} - + {{--============================================================================================================--}} + + {{-- Simple Table Sorter --}} + + {{-- This script works on any tables with the "sortable" class. There's no extra setup needed here. --}} + @endpush -- 2.39.5