From a1c806e50fe83a6fd8146b4c5fa74dbb81ccce00 Mon Sep 17 00:00:00 2001 From: Don Restarone <35935196+donrestarone@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:59:54 -0500 Subject: [PATCH] [feature] Entity specific analytics (#1389) * [feature] Entity specific analytics Addresses: https://github.com/restarone/violet_rails/issues/1292 Screen Shot 2023-01-04 at 8 07 53 AM ### Determining the visibility threshold for section views Threshold indicates at what percentage of the target's visibility the tracking should be executed. 0.75 threshold indicates that the tracking is done when 75% of the section is visible in the screen. However for larger sections, 75% of the content can never be visible. Therefore we need to adjust the threshold. A good rule of thumb is if the section is double the screen size, adjust the threshold to slightly less than 0.5 and if the section is 4 times the screen size, adjust threshold to less than 0.25, and so on REF: https://stackoverflow.com/questions/66296057/intersectionobserver-does-not-work-on-small-screens-for-long-sections-js/ https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#thresholds Co-authored-by: Pralish Kayastha <50227291+Pralish@users.noreply.github.com> Co-authored-by: Pralish Kayastha --- Dockerfile.dev | 1 + app/assets/config/manifest.js | 1 + .../javascripts/comfy/admin/cms/dashboard.js | 39 +++ app/assets/stylesheets/application.scss | 1 + app/assets/stylesheets/dashboard.scss | 172 ++++++++++++ .../comfy/admin/api_namespaces_controller.rb | 26 +- .../comfy/admin/v2/dashboard_controller.rb | 53 ++++ app/helpers/api_forms_helper.rb | 7 +- app/helpers/dashboard_helper.rb | 118 +++++++- app/javascript/packs/violet_analytics.js | 120 +++++++++ app/models/ahoy/event.rb | 13 + app/views/comfy/admin/api_forms/_render.haml | 2 +- .../comfy/admin/api_namespaces/show.html.haml | 17 ++ .../admin/cms/partials/_navigation_inner.haml | 3 +- .../dashboard/_events_search_filters.haml | 2 +- .../comfy/admin/dashboard/events_detail.haml | 4 +- .../admin/v2/dashboard/_events.html.haml | 30 +++ .../admin/v2/dashboard/dashboard.html.haml | 255 ++++++++++++++++++ app/views/layouts/comfy/admin/cms.html.haml | 2 +- .../layouts/comfy/admin/cms/_body.html.haml | 7 +- app/views/layouts/website.html.erb | 3 +- .../initializers/comfortable_mexican_sofa.rb | 2 +- config/routes.rb | 3 + ...add_analytics_metadata_to_api_namespace.rb | 5 + db/schema.rb | 3 +- package.json | 2 + .../comfy/api_namespaces_controller_test.rb | 43 ++- .../comfy/v2/dashboard_controller_test.rb | 96 +++++++ test/helpers/dashboard_helper_test.rb | 139 ++++++++++ yarn.lock | 18 ++ 30 files changed, 1163 insertions(+), 24 deletions(-) create mode 100644 app/assets/javascripts/comfy/admin/cms/dashboard.js create mode 100644 app/controllers/comfy/admin/v2/dashboard_controller.rb create mode 100644 app/javascript/packs/violet_analytics.js create mode 100644 app/views/comfy/admin/v2/dashboard/_events.html.haml create mode 100644 app/views/comfy/admin/v2/dashboard/dashboard.html.haml create mode 100644 db/migrate/20230124014921_add_analytics_metadata_to_api_namespace.rb create mode 100644 test/controllers/admin/comfy/v2/dashboard_controller_test.rb diff --git a/Dockerfile.dev b/Dockerfile.dev index 79a059cae..ae0c4a12a 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -20,6 +20,7 @@ RUN apk --no-cache update \ && apk --no-cache upgrade \ && apk add --no-cache \ build-base \ +python2 \ ttf-ubuntu-font-family \ git \ postgresql-dev \ diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 591819335..61d823638 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,2 +1,3 @@ //= link_tree ../images //= link_directory ../stylesheets .css +//= link_tree ../javascripts diff --git a/app/assets/javascripts/comfy/admin/cms/dashboard.js b/app/assets/javascripts/comfy/admin/cms/dashboard.js new file mode 100644 index 000000000..4d3a159a8 --- /dev/null +++ b/app/assets/javascripts/comfy/admin/cms/dashboard.js @@ -0,0 +1,39 @@ +//= require moment +//= require bootstrap-daterangepicker + +$(function() { + $('[data-toggle="tooltip"]').tooltip(); + + $('#reportrange').daterangepicker({ + opens: 'left', + locale: { + format: 'YYYY/MM/DD' + }, + startDate: $("#start_date").val() || moment().startOf('month'), + endDate: $("#end_date").val() || moment().endOf('month'), + ranges: { + [moment().format('MMMM YYYY')]: [moment().startOf('month'), moment().endOf('month')], + '3 months': [moment().startOf('month').subtract(2, 'months'), moment().endOf('month')], + '6 months': [moment().startOf('month').subtract(5, 'months'), moment().endOf('month')], + '1 year': [moment().startOf('month').subtract(11, 'months'), moment().endOf('month')] + } + }, function(start, end, label) { + $('#start_date').val(start.format('YYYY-MM-DD')); + $('#end_date').val(end.format('YYYY-MM-DD')); + $('#interval').val(label); + cb(); + $("#analytics_filter").submit(); + }); + + cb(); +}); + +function cb() { + if (!$('#start_date').val() && !$('#end_date').val()) { + $('#reportrange span').html(moment().format('MMMM YYYY')); + } else if ($('#interval').val() == 'Custom Range') { + $('#reportrange span').html(moment($("#start_date").val()).format('MMMM D, YYYY') + ' - ' + moment($("#end_date").val()).format('MMMM D, YYYY')); + } else { + $('#reportrange span').html($('#interval').val()); + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index a016c34e7..3ff838157 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -19,6 +19,7 @@ @import "./actiontext.scss"; @import 'select2/dist/css/select2.css'; @import './direct_upload.scss'; + @import 'bootstrap-daterangepicker/daterangepicker'; .alert-file_url { display: none; diff --git a/app/assets/stylesheets/dashboard.scss b/app/assets/stylesheets/dashboard.scss index 4f1963761..d6f102422 100755 --- a/app/assets/stylesheets/dashboard.scss +++ b/app/assets/stylesheets/dashboard.scss @@ -1,3 +1,175 @@ // Place all the styles related to the dashboard controller here. // They will automatically be included in application.css. // You can use Sass (SCSS) here: https://sass-lang.com/ + +.vr-analytics { + color: #293845; + + &-title { + font-size: 32px; + line-height: 44px; + font-weight: bold; + } + + &-sub-title { + font-size: 24px; + line-height: 33px; + text-transform: capitalize; + } + + &-count, + &-percent-change, + &-event-label { + color: #535F6A; + font-size: 16px; + line-height: 22px; + } + + &-count { + margin-right: 10px; + + @media (min-width: 768px) { + margin-left: 24px; + } + } + + &-count-lg { + font-size: 24px; + line-height: 33px; + margin-right: 10px; + } + + &-tooltips { + height: 16px; + width: 16px; + border-radius: 50%; + background-color: #D4D7DA; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + margin-left: 8px; + cursor: default; + } + + &-percent-change { + .positive { + color: #63C32D; + } + + .negative { + color: #E93E3E; + } + } + + &-filter-dropdown-container { + & > button { + color: #BD34D1; + font-size: 16px; + font-weight: 600; + } + } + + &-filters { + &-pages { + border: none; + outline: none; + -moz-appearance:none; /* Firefox */ + -webkit-appearance:none; /* Safari and Chrome */ + appearance:none; + background: transparent; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position-x: 98.5%; + background-position-y: 50%; + } + + &-pages, + &-ranges > span, + &-ranges > i { + color: #BD34D1; + font-size: 16px; + font-weight: 600; + cursor: pointer; + } + + @media (max-width: 767px) { + &-pages, + &-ranges { + width: 100%; + border: 1px solid #E5E7E8; + border-radius: 5px; + padding: 9px 16px; + margin-bottom: 8px; + } + } + + } + + &-card { + box-shadow: -4px 12px 24px #29384514; + border: 1px solid #E9EAEC; + border-radius: 5px; + position: relative; + + & > a { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + } + + &-section { + padding: 16px; + } + + &-resource-poster-conatiner { + margin-right: 10px; + + & > img { + height: 48px; + width: 85px; + object-fit: cover; + } + } + + &-resource-title { + font-weight: bold; + position: relative; + pointer-events: none; + z-index: 1; + + & > a { + color: #293845; + pointer-events: all; + } + } + + &-resource-duration { + color: #535F6A; + } + } + + &-section { + padding: 32px 0; + + &-header { + margin-bottom: 24px; + } + } + + &-page-visit-events { + &-donut-chart { + padding: 16px; + } + } + + + &-blank-states { + color: grey; + padding: 0 25px 40px; + font-size: 14px; + } +} \ No newline at end of file diff --git a/app/controllers/comfy/admin/api_namespaces_controller.rb b/app/controllers/comfy/admin/api_namespaces_controller.rb index a79c01619..228b13415 100755 --- a/app/controllers/comfy/admin/api_namespaces_controller.rb +++ b/app/controllers/comfy/admin/api_namespaces_controller.rb @@ -1,7 +1,7 @@ require 'will_paginate/array' class Comfy::Admin::ApiNamespacesController < Comfy::Admin::Cms::BaseController - before_action :set_api_namespace, only: %i[ show edit update destroy discard_failed_api_actions rerun_failed_api_actions export export_api_resources duplicate_with_associations duplicate_without_associations export_without_associations_as_json export_with_associations_as_json social_share_metadata api_action_workflow ] + before_action :set_api_namespace, except: %i[index new create import_as_json] before_action :ensure_authority_for_creating_api, only: %i[ new create import_as_json] before_action :ensure_authority_for_viewing_all_api, only: :index @@ -12,6 +12,7 @@ class Comfy::Admin::ApiNamespacesController < Comfy::Admin::Cms::BaseController before_action :ensure_authority_for_allow_exports_in_api, only: %i[ export export_api_resources export_without_associations_as_json export_with_associations_as_json ] before_action :ensure_authority_for_allow_duplication_in_api, only: %i[ duplicate_with_associations duplicate_without_associations ] before_action :ensure_authority_for_allow_social_share_metadata_in_api, only: %i[ social_share_metadata ] + before_action :ensure_authority_to_manage_analytics, only: :analytics_metadata before_action :ensure_authority_for_full_access_for_api_actions_only_in_api, only: %i[ api_action_workflow discard_failed_api_actions rerun_failed_api_actions ] # GET /api_namespaces or /api_namespaces.json @@ -219,6 +220,25 @@ def social_share_metadata end end + + def analytics_metadata + respond_to do |format| + if @api_namespace.update(analytics_metadata_params) + format.html do + flash[:notice] = 'Analytics Metadata successfully updated.' + redirect_to @api_namespace + end + format.json { render :show, status: :ok, location: @api_namespace } + else + format.html do + flash[:error] = @api_namespace.errors.full_messages + render :edit, status: :unprocessable_entity + end + format.json { render json: @api_namespace.errors, status: :unprocessable_entity } + end + end + end + def api_action_workflow respond_to do |format| if @api_namespace.update(api_action_workflow_params) @@ -269,4 +289,8 @@ def api_action_workflow_params def api_namespace_social_share_metadata_params params.require(:api_namespace).permit(social_share_metadata: [:title, :description, :image]) end + + def analytics_metadata_params + params.require(:api_namespace).permit(analytics_metadata: [:title, :author, :thumbnail]) + end end \ No newline at end of file diff --git a/app/controllers/comfy/admin/v2/dashboard_controller.rb b/app/controllers/comfy/admin/v2/dashboard_controller.rb new file mode 100644 index 000000000..623127db9 --- /dev/null +++ b/app/controllers/comfy/admin/v2/dashboard_controller.rb @@ -0,0 +1,53 @@ +class Comfy::Admin::V2::DashboardController < Comfy::Admin::Cms::BaseController + include AhoyEventsHelper + + before_action :ensure_authority_to_manage_analytics + + def dashboard + @start_date = params[:start_date]&.to_date || Date.today.beginning_of_month + @end_date = params[:end_date]&.to_date || Date.today.end_of_month + date_range = @start_date.beginning_of_day..@end_date.end_of_day + + @visits = Ahoy::Visit.where(started_at: @start_date.beginning_of_day..@end_date.end_of_day) + + Ahoy::Event::EVENT_CATEGORIES.values.each do |event_category| + if event_category == Ahoy::Event::EVENT_CATEGORIES[:page_visit] + events = Ahoy::Event.where(name: 'comfy-cms-page-visit').joins(:visit) + else + events = Ahoy::Event.jsonb_search(:properties, { category: event_category }).joins(:visit) + end + events = events.jsonb_search(:properties, { page_id: params[:page] }) if params[:page].present? + instance_variable_set("@previous_period_#{event_category}_events", events.where(time: previous_period(params[:interval], @start_date, @end_date))) + instance_variable_set("@#{event_category}_events", events.where(time: date_range)) + end + + # legacy and system events does not have category + # separating out 'comfy-cms-page-visit' event since we have a seprate section + @legacy_and_system_events = Ahoy::Event.where.not('properties::jsonb ? :key', key: 'category').where.not(name: 'comfy-cms-page-visit').joins(:visit) + @previous_period_legacy_and_system_events = @legacy_and_system_events.where(time: previous_period(params[:interval], @start_date, @end_date)) + @legacy_and_system_events = @legacy_and_system_events.where(time: date_range) + end + + + private + + def previous_period(interval, start_date, end_date) + today = Date.current + interval = interval || today.strftime('%B %Y') + + case interval + when "#{today.strftime('%B %Y')}" + today.prev_month.beginning_of_month.beginning_of_day..today.prev_month.end_of_month.end_of_day + when "3 months" + (start_date - 3.months).beginning_of_month.beginning_of_day..(start_date - 1.month).end_of_month.end_of_day + when "6 months" + (today - 6.months).beginning_of_month.beginning_of_day..(start_date - 1.month).end_of_month.end_of_day + when "1 year" + (today - 12.months).beginning_of_month.beginning_of_day..(start_date - 1.month).end_of_month.end_of_day + else + + days_diff = (end_date - start_date).to_i + (start_date - (days_diff + 1).days).beginning_of_day..(start_date - 1.day).end_of_day + end + end +end \ No newline at end of file diff --git a/app/helpers/api_forms_helper.rb b/app/helpers/api_forms_helper.rb index 79cbd28ec..51f9f7857 100644 --- a/app/helpers/api_forms_helper.rb +++ b/app/helpers/api_forms_helper.rb @@ -1,11 +1,14 @@ module ApiFormsHelper - def render_form(id) + def render_form(id, options = {}) # usage in cms {{ cms:helper render_form, 1 }} here 1 is the id # usage in rails = render_form @api_form.id @api_form = ApiForm.find_by(id: id) if @api_form @api_namespace = @api_form.api_namespace - render partial: 'comfy/admin/api_forms/render' + data = options['data'] || {} + + data["violet-track-form-submit"] = "true" unless data["violet-track-form-submit"].present? + render partial: 'comfy/admin/api_forms/render', locals: { data: data } end end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index 0237b66b8..d94685df8 100755 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -21,4 +21,120 @@ def session_detail_title user_visit_count = Ahoy::Visit.where(user_id: params[:id]).where('started_at <= ?', @visit.started_at).size "Visit (#{user_visit_count})" end -end + + def page_visit_chart_data(page_visit_events, start_date, end_date) + period, format = split_into(start_date, end_date) + page_visit_events.where.not('ahoy_visits.device_type': nil).group_by { |u| u.visit.device_type }.map do |key, value| + { name: key, data: Ahoy::Event.where(id: value.pluck(:id)).group_by_period(period, :time, range: start_date..end_date, format: format).count } + end + end + + def page_name(page_id) + return 'Website' if page_id.blank? + + Comfy::Cms::Page.find_by(id: page_id)&.label + end + + def visitors_chart_data(visits) + visitors_by_token = visits.group(:visitor_token).count + recurring_visitors = visitors_by_token.values.count { |v| v > 1 } + single_time_visitors = visitors_by_token.keys.count - recurring_visitors + {"Single time visitor": single_time_visitors, "Recurring visitors" => recurring_visitors } + end + + def display_percent_change(current_count, prev_count) + return if prev_count == 0 + + percent_change = percent_change(current_count, prev_count) + + raw("
0 ? 'positive' : 'negative' }\"> 0 ? 'up' : 'down' }\">#{percent_change.round(2).abs} %
") + end + + def tooltip_content(current_count, prev_count, interval, start_date, end_date) + prev_interval = previous_interval(interval, start_date, end_date) + return "There's no data from the previous #{prev_interval} to compare" if prev_count.zero? + + return "There's no change compared the previous #{prev_interval}" if current_count == prev_count + + percent_change = percent_change(current_count, prev_count) + "This is a #{percent_change.round(2).abs} % #{percent_change > 0 ? 'increase': 'decrease'} compared to the previous #{prev_interval}" + end + + def total_watch_time(video_view_events) + video_view_events.sum { |event| event.properties['watch_time'].to_i } + end + + def to_minutes(time_in_milisecond) + "#{number_with_delimiter((time_in_milisecond.to_f / (1000 * 60)).round(2) , :delimiter => ',')} min" + end + + def total_views(video_view_events) + video_view_events.select { |event| event.properties['video_start'] }.size + end + + def avg_view_duration(video_view_events) + total_watch_time(video_view_events).to_f / (total_views(video_view_events).nonzero? || 1) + end + + def avg_view_percentage(video_view_events) + view_percentage_arr = video_view_events.group_by { |event| event.properties['resource_id'] }.map do |_resource_id, events| + (events.sum { |event| event.properties['watch_time'].to_f / event.properties['total_duration'].to_f }) * 100 + end + view_percentage_arr.sum / (total_views(video_view_events).nonzero? || 1) + end + + def top_three_videos(video_view_events, previous_video_view_events) + video_view_events.group_by { |event| event.properties['resource_id'] }.map do |resource_id, events| + previous_period_event = previous_video_view_events.jsonb_search(:properties, { resource_id: resource_id }) + api_resource = ApiResource.find_by(id: resource_id) + { + total_views: total_views(events), + total_watch_time: total_watch_time(events), + previous_period_total_views: total_views(previous_period_event), + previous_period_total_watch_time: total_watch_time(previous_period_event), + resource_title: api_resource&.properties.dig(api_resource&.api_namespace.analytics_metadata&.dig("title")) || "Resource Id: #{resource_id}", + resource_author: api_resource&.properties.dig(api_resource&.api_namespace.analytics_metadata&.dig("author")), + resource_image: api_resource&.non_primitive_properties.find_by(field_type: "file", label: api_resource&.api_namespace.analytics_metadata&.dig("thumbnail"))&.file_url, + resource_id: api_resource&.id, + namespace_id: api_resource&.api_namespace.id, + duration: events.first.properties['total_duration'], + name: events.first.name + } + end.sort_by {|event| event[:total_views]}.reverse.first(3) + end + + private + def split_into(start_date, end_date) + time_in_days = (end_date - start_date).to_i + + if time_in_days <= 1 + [:hour_of_day, '%I %P'] + elsif time_in_days <= 7 + [:day, '%-d %^b %Y'] + elsif time_in_days <= 30 + [:week, '%V'] + elsif time_in_days <= 365 + [:month, '%^b %Y'] + else + [:year, '%Y'] + end + end + + def previous_interval(interval, start_date, end_date) + today = Date.current + interval = interval || today.strftime('%B %Y') + + case interval + when "3 months", "6 months", "1 year" + interval + when "#{today.strftime('%B %Y')}" + "month" + else + "#{ (end_date - start_date).to_i } days" + end + end + + def percent_change(current_count, prev_count) + ((current_count - prev_count).to_f / prev_count) * 100 + end +end \ No newline at end of file diff --git a/app/javascript/packs/violet_analytics.js b/app/javascript/packs/violet_analytics.js new file mode 100644 index 000000000..f9972ef30 --- /dev/null +++ b/app/javascript/packs/violet_analytics.js @@ -0,0 +1,120 @@ +const VIOLET_EVENT_CATEGORIES = { + page_visit: 'page_visit', + click: 'click', + form_submit: 'form_submit', + video_view: 'video_view', + section_view: 'section_view', +} +var analyticsLoaded = false; + + +$(window).on("load turbo:load", () => { + if (analyticsLoaded) { return ; } + + const pageId = $('body').data('page-id'); + const analyticsBrowserStorageKey = `violet_analytics_page_${pageId}`; + + let sectionsViewedMap = {}; + + $('[data-violet-track-section-view="true"]').each(function() { + sectionsViewedMap[this.dataset.violetEventName] = false; + }) + + sessionStorage.setItem(analyticsBrowserStorageKey, JSON.stringify({sectionsViewedMap})); + + $('[data-violet-track-click="true"]').on('click', function() { + const eventName = this.dataset.violetEventName || VIOLET_EVENT_CATEGORIES.click; + const eventLabel = this.dataset.violetEventLabel || eventName; + ahoy.track(eventName, { + category: VIOLET_EVENT_CATEGORIES.click, + label: eventLabel, + page_id: pageId, + tag: this.tagName, + href: this.href + }) + }) + + // Usage: {{ cms:helper render_form, 1, { data: { violet-track-form-submit: true, violet-event-name: 'contact_form_submit', violet-event-label: 'Contact form Submission' } } }} + $('[data-violet-track-form-submit="true"]').on('submit', function() { + const eventName = this.dataset.violetEventName || `${this.dataset.slug}-form-submit`; + const eventLabel = this.dataset.violetEventLabel || `${this.dataset.slug} Form`; + ahoy.track(eventName, { + category: VIOLET_EVENT_CATEGORIES.form_submit, + label: eventLabel, + page_id: pageId, + }) + }) + + // Usage: + // + $('[data-violet-track-video-view="true"]').each( function() { + var startTime; + const eventName = this.dataset.violetEventName || VIOLET_EVENT_CATEGORIES.video_view; + const eventLabel = this.dataset.violetEventLabel || eventName; + var resourceId = this.dataset.violetResourceId; + // Count paused and then played video as a single view. + var isFirstPlay = true; + + $(this).on("play", function() { + if (this.seeking) { return; } + + startTime = Date.now(); + }); + + $(this).on("pause ended", function(e) { + if (this.seeking) { return; } + + var watchTime = Date.now() - startTime; + ahoy.track(eventName, { + category: VIOLET_EVENT_CATEGORIES.video_view, + label: eventLabel, + page_id: pageId, + resource_id: resourceId, + video_start: isFirstPlay, + watch_time: watchTime, + total_duration: this.duration * 1000 + }) + + // replay counts as a new view event + isFirstPlay = e.type == 'ended' + }); + }) + + // track section views + $('[data-violet-track-section-view="true"]').each(function() { + trackSectionViews(this, pageId) + }) + + analyticsLoaded = true; +}) + +$(window).on('beforeunload turbo:before-visit', function() { + // trigger pause event on videos + $('[data-violet-track-video-view="true"]').each( function() { this.pause(); }) + analyticsLoaded = false; +}) + +function trackSectionViews(target, pageId) { + const eventName = target.dataset.violetEventName; + const observerOptions = { root: null, threshold: target.dataset.violetVisibilityThreshold || 0.75 }; + const analyticsBrowserStorageKey = `violet_analytics_page_${pageId}` + + const observer = new IntersectionObserver((entries) => { + const analyticsStorage = JSON.parse(sessionStorage.getItem(analyticsBrowserStorageKey)); + const entry = entries[0]; + const targetHasBeenViewed = analyticsStorage.sectionsViewedMap[eventName]; + if (entry.isIntersecting && !targetHasBeenViewed) { + ahoy.track(eventName, { + category: VIOLET_EVENT_CATEGORIES.section_view, + label: target.dataset.violetEventLabel || eventName, + page_id: pageId + }); + analyticsStorage.sectionsViewedMap[eventName] = true; + sessionStorage.setItem(analyticsBrowserStorageKey, JSON.stringify(analyticsStorage)); + } + }, observerOptions); + + observer.observe(target); +} \ No newline at end of file diff --git a/app/models/ahoy/event.rb b/app/models/ahoy/event.rb index 3eedf28bd..e8a4f62ed 100644 --- a/app/models/ahoy/event.rb +++ b/app/models/ahoy/event.rb @@ -1,5 +1,6 @@ class Ahoy::Event < ApplicationRecord include Ahoy::QueryMethods + include JsonbSearch::Searchable SYSTEM_EVENTS = { 'comfy-blog-page-visit'=> 0, @@ -13,6 +14,14 @@ class Ahoy::Event < ApplicationRecord 'api-resource-create' => 8 } + EVENT_CATEGORIES = { + page_visit: 'page_visit', + click: 'click', + video_view: 'video_view', + form_submit: 'form_submit', + section_view: 'section_view' + } + self.table_name = "ahoy_events" belongs_to :visit @@ -32,6 +41,10 @@ class Ahoy::Event < ApplicationRecord Arel.sql('distinct_name') end + def label + properties["label"] || name + end + def self.delete_specific_events_and_associated_visits(delete_events: false, event_type:) begin ActiveRecord::Base.transaction do diff --git a/app/views/comfy/admin/api_forms/_render.haml b/app/views/comfy/admin/api_forms/_render.haml index 6068b17bd..6c7a09f81 100644 --- a/app/views/comfy/admin/api_forms/_render.haml +++ b/app/views/comfy/admin/api_forms/_render.haml @@ -8,7 +8,7 @@ - recaptcha_keys_set = @api_form.show_recaptcha_v3 && ENV['RECAPTCHA_SITE_KEY_V3'].present? && ENV['RECAPTCHA_SECRET_KEY_V3'].present? unless recaptcha_keys_set - if (@api_form.show_recaptcha.blank? && @api_form.show_recaptcha_v3.blank?) || recaptcha_keys_set - = form_for :data, url: api_namespace_resource_index_path(api_namespace_id: @api_namespace.id), method: :post, html: {class: 'violet-cta-form', 'data-type': 'json', multipart: true, id: "api_form_#{@api_form.object_id}", onsubmit: 'disableForm(this); return checkRequiredRichTextFields()'}, data: { turbo: false, remote: true } do |f| + = form_for :data, url: api_namespace_resource_index_path(api_namespace_id: @api_namespace.id), method: :post, html: {class: 'violet-cta-form', 'data-type': 'json', multipart: true, id: "api_form_#{@api_form.object_id}", onsubmit: 'disableForm(this); return checkRequiredRichTextFields()'}, data: { turbo: false, remote: true, slug: @api_namespace.slug }.merge(data) do |f| = hidden_field_tag :form_id, @api_form.object_id .form-group - properties.each do |key, value| diff --git a/app/views/comfy/admin/api_namespaces/show.html.haml b/app/views/comfy/admin/api_namespaces/show.html.haml index 3f08b754e..ebf447a76 100755 --- a/app/views/comfy/admin/api_namespaces/show.html.haml +++ b/app/views/comfy/admin/api_namespaces/show.html.haml @@ -43,6 +43,9 @@ %li.nav-item %a#social-tab.nav-link{"aria-controls" => "social", "aria-selected" => "false", "data-toggle" => "tab", :href => "#social", :role => "tab"} Social Share Mapping + %li.nav-item + %a#analytics-mapping-tab.nav-link{"aria-controls" => "analytics-mapping", "aria-selected" => "false", "data-toggle" => "tab", :href => "#analytics-mapping", :role => "tab"} + Analytics Mapping .tab-content #interface.tab-pane.fade.show.active{"aria-labelledby" => "interface-tab", :role => "tabpanel"} @@ -197,6 +200,20 @@ = f.select 'api_namespace[social_share_metadata][image]', options_for_select(@image_options,(@api_namespace.social_share_metadata.present? ? @api_namespace.social_share_metadata["image"] : nil)),{include_blank: "Choose an option"}, {class: 'form-control'} .form-group = f.submit "Submit", class: 'btn btn-primary' + + #analytics-mapping.tab-pane.fade{"aria-labelledby" => "analytics-mapping-tab", :role => "tabpanel"} + = form_with(method: :patch , url: analytics_metadata_api_namespace_path(@api_namespace)) do |f| + .form-group + = f.label :title + = f.select 'api_namespace[analytics_metadata][title]', options_for_select(@api_namespace.properties.keys,(@api_namespace.analytics_metadata&.dig('title'))),{include_blank: "Choose an option"}, {class: 'form-control'} + .form-group + = f.label :author + = f.select 'api_namespace[analytics_metadata][author]', options_for_select(@api_namespace.properties.keys,(@api_namespace.analytics_metadata&.dig('author'))),{include_blank: "Choose an option"}, {class: 'form-control'} + .form-group + = f.label :thumbnail + = f.select 'api_namespace[analytics_metadata][thumbnail]', options_for_select(@image_options,(@api_namespace.analytics_metadata&.dig('thumbnail'))),{include_blank: "Choose an option"}, {class: 'form-control'} + .form-group + = f.submit "Submit", class: 'btn btn-primary' -# We show this only if the user has full_access or access related to api-resources - if has_access_to_api_accessibility?(ApiNamespace::API_ACCESSIBILITIES[:read_api_resources_only], current_user, @api_namespace) diff --git a/app/views/comfy/admin/cms/partials/_navigation_inner.haml b/app/views/comfy/admin/cms/partials/_navigation_inner.haml index 45085acb0..043d593e6 100644 --- a/app/views/comfy/admin/cms/partials/_navigation_inner.haml +++ b/app/views/comfy/admin/cms/partials/_navigation_inner.haml @@ -3,7 +3,8 @@ = active_link_to "Email", mailbox_path, class: 'nav-link' = active_link_to "API", api_namespaces_path, class: 'nav-link' = active_link_to "App Settings", edit_web_settings_path, class: 'nav-link' - = active_link_to "Visitor Analytics", dashboard_path, class: 'nav-link' + = active_link_to "Analytics", dashboard_path, class: 'nav-link' + = active_link_to "Analytics V2", v2_dashboard_path, class: 'nav-link' = active_link_to "My Settings", edit_user_registration_path, class: 'nav-link' %li{class: 'nav-item'} %li.nav-item.dropdown diff --git a/app/views/comfy/admin/dashboard/_events_search_filters.haml b/app/views/comfy/admin/dashboard/_events_search_filters.haml index 9081eae9b..b86f68eb5 100644 --- a/app/views/comfy/admin/dashboard/_events_search_filters.haml +++ b/app/views/comfy/admin/dashboard/_events_search_filters.haml @@ -10,7 +10,7 @@ = f.search_field :visit_ip_in, value: params.dig(:q, :visit_ip_in), class: 'form-control' = f.label "IP addresses (exclude)", class: 'col-form-label' = f.search_field :visit_ip_does_not_match_all, value: params.dig(:q, :visit_ip_does_not_match_all), class: 'form-control' - .form-row.p-5 + .form-row.py-4.px-2.py-md-5.px-md-5 .col = f.label "start date", class: 'col-form-label' = f.search_field :visit_started_at_gteq, class: 'form-control', type: 'date', value: params.dig(:q, :visit_started_at_gteq) diff --git a/app/views/comfy/admin/dashboard/events_detail.haml b/app/views/comfy/admin/dashboard/events_detail.haml index 3c0f38a12..583415c8b 100644 --- a/app/views/comfy/admin/dashboard/events_detail.haml +++ b/app/views/comfy/admin/dashboard/events_detail.haml @@ -7,7 +7,7 @@ - unless Ahoy::Event::SYSTEM_EVENTS.keys.include?(params[:ahoy_event_type]) = link_to 'Delete', dashboard_destroy_event_path(ahoy_event_type: params[:ahoy_event_type]), method: :delete, data: { confirm: 'This will delete all such events and its associated visits data. Are you sure you?' }, class: 'btn btn-sm btn-danger' -%main{class: 'm-5'} +%main.my-4.mx-2.my-md-5.mx-md-5 = render partial: 'events_search_filters' %h4.mb-5.font-weight-bold @@ -34,7 +34,7 @@ = geo_chart @events.group('ahoy_visits.country').count, label: "events" .col-12.my-5 - .row + .d-flex.flex-wrap.justify-content-around .mx-5.my-3 %p By device type = pie_chart @events.where.not('ahoy_visits.device_type': nil).group('ahoy_visits.device_type').count, width: "300px", height: "300px", label: "events" diff --git a/app/views/comfy/admin/v2/dashboard/_events.html.haml b/app/views/comfy/admin/v2/dashboard/_events.html.haml new file mode 100644 index 000000000..5e1d2617e --- /dev/null +++ b/app/views/comfy/admin/v2/dashboard/_events.html.haml @@ -0,0 +1,30 @@ +%section.vr-analytics-section.vr-analytics-events + .vr-analytics-section-header + .d-md-flex.align-items-center + .vr-analytics-sub-title + = title + - if events.present? + .d-flex.align-items-center.mt-2.mt-md-0 + .vr-analytics-count + = events.count + = "total #{type}" + .vr-analytics-percent-change + = display_percent_change(events.count, previous_period_events.count) + .vr-analytics-tooltips{ data: { toggle: "tooltip", placement: "right" }, title: tooltip_content(events.count, previous_period_events.count, params[:interval], @start_date, @end_date) } + ? + + - if events.present? + .vr-analytics-section-body.d-flex.align-items-center + .vr-analytics-events-grid.row.w-100 + - previous_grouped_events = previous_period_events.group(:name).size + - events.group_by(&:label).each do |key, value| + .vr-analytics-events-grid-item.col.col-12.col-sm-6.col-md-4.col-lg-3.mb-4 + .d-flex.mr-4.align-items-center.mb-2 + .vr-analytics-count-lg + = value.count + .vr-analytics-percent-change + = display_percent_change(value.count, previous_grouped_events[key].to_i) + - if previous_grouped_events[key].to_i == 0 + .vr-analytics-tooltips{ data: { toggle: "tooltip", placement: "right" }, title: tooltip_content(value.count, 0, params[:interval], @start_date, @end_date) } + ? + = link_to key, dashboard_events_path(ahoy_event_type: value.first&.name), class: 'vr-analytics-event-label' \ No newline at end of file diff --git a/app/views/comfy/admin/v2/dashboard/dashboard.html.haml b/app/views/comfy/admin/v2/dashboard/dashboard.html.haml new file mode 100644 index 000000000..58801d9b9 --- /dev/null +++ b/app/views/comfy/admin/v2/dashboard/dashboard.html.haml @@ -0,0 +1,255 @@ += javascript_include_tag "//www.google.com/jsapi" +.vr-analytics + .page-header + .row.my-2.justify-content-between.align-items-center.navbar.navbar-expand-md.p-0 + .vr-analytics-title.col.col-6.col-md-3 + Dashboard + .vr-analytics-filter-dropdown-container.col.col-6.d-flex.d-md-none.justify-content-end + %button{"aria-controls" => "navbarSupportedContent", "aria-expanded" => "false", "aria-label" => "Toggle navigation", class:"navbar-toggler", "data-target" => "#filter-collapse", "data-toggle" => "collapse", type: "button"} + %i.fas.fa-filter.mr-2 + Filters + + .vr-analytics-filters.col.col-md-9.justify-content-end.navbar-collapse.collapse{id: 'filter-collapse'} + = form_with(url: v2_dashboard_path, method: :get, html: {id: 'analytics_filter' }) do |f| + .d-md-flex.justify-content-end.mt-3.mt-md-0 + = f.hidden_field :start_date, value: params[:start_date] + = f.hidden_field :end_date, value: params[:end_date] + = f.hidden_field :interval, value: params[:interval] + = f.select :page, options_for_select(Subdomain.current.pages.map {|page| [page.label, page.id] }, params[:page]), { prompt: "All pages"}, { class: 'vr-analytics-filters-pages', onchange: "$(this).closest('form').submit()" } + #reportrange.vr-analytics-filters-ranges.ml-md-4.d-flex.d-md-block.justify-content-between + %span + %i.fa.fa-caret-down.ml-2 + + %main{class: 'my-5'} + %section.row.vr-analytics-section.vr-analytics-page-visit-events + .col{ class: @page_visit_events.present? ? "col-lg-9 mb-4" : "col-12" } + .d-flex.justify-content-between + .vr-analytics-section-header.d-md-flex.justify-content-between.align-items-center + .vr-analytics-sub-title + = "#{page_name(params[:page])} visits" + - if @page_visit_events.present? + .d-flex.justify-content-between.align-items-center.mt-2.mt-md-0 + .vr-analytics-count + = @page_visit_events.count + total visits + .vr-analytics-percent-change + = display_percent_change(@page_visit_events.count, @previous_period_page_visit_events.count) + .vr-analytics-tooltips{ data: { toggle: "tooltip", placement: "right" }, title: tooltip_content(@page_visit_events.count, @previous_period_page_visit_events.count, params[:interval], @start_date, @end_date) } + ? + .vr-analytics-section-body + - if @page_visit_events.present? + = column_chart page_visit_chart_data(@page_visit_events, @start_date, @end_date), colors: ["#88DAE3FF", "#D785E3FF", "#F7C85CFF"], library: { plugins: { legend: { position: "top", align: "end", labels: { padding: 24, boxWidth: 8, usePointStyle: true, font: { size: 16 } } } } } + - else + .vr-analytics-blank-states + There are no page visit events within the selected date range. + %br + Use 'data-violet-track-page-visit="true"' attribute on a html attribute to track page visits. + %br + %br + %code + :escaped +
+ %br + :escaped + . + %br + :escaped + . + %br + :escaped +
+ - if @page_visit_events.present? + .col.col-lg-3 + .vr-analytics-card.vr-analytics-page-visit-events-donut-chart + %h5 Website visitors + = pie_chart visitors_chart_data(@visits), colors: ['#F7A47B', '#B5E69A'], donut: true, library: { cutout: 85, plugins: { legend: { position: "bottom", align: 'start', labels: {boxWidth: 8, usePointStyle: true, font: { size: 16 } } } } } + + + %hr.m-0 + = render partial: 'events', locals: { events: @click_events, previous_period_events: @previous_period_click_events, title: 'Clicks', type: 'clickables' } + - unless @click_events.present? + .vr-analytics-blank-states + There are no click events within the selected date range. + %br + Use 'data-violet-track-click="true"' attribute on a html element to track clicks. + %br + %br + %code + :escaped + Test + + %hr.m-0 + + %section.vr-analytics-section + .vr-analytics-section-header + .row.align-items-center + .vr-analytics-sub-title.col.col-12.col-md-2 + Watch time + + - if @video_view_events.present? + .col.col-md-10 + .row + - watch_time = total_watch_time(@video_view_events) + - previous_watch_time = total_watch_time(@previous_period_video_view_events) + .col.col-12.col-sm-4.d-flex.align-items-center.my-3.my-sm-0 + .vr-analytics-count.ml-0 + Total watch time + = to_minutes(watch_time) + .vr-analytics-percent-change + = display_percent_change(watch_time, previous_watch_time) + .vr-analytics-tooltips{ data: { toggle: "tooltip", placement: "right" }, title: tooltip_content(watch_time, previous_watch_time, params[:interval], @start_date, @end_date) } + ? + + .col.col-12.col-sm-4.d-flex.align-items-center.mb-3.mb-sm-0 + .vr-analytics-count.ml-0 + Avg. view duration + - view_duration = avg_view_duration(@video_view_events) + - previous_view_duration = avg_view_duration(@previous_period_video_view_events) + = to_minutes(view_duration) + .vr-analytics-percent-change + = display_percent_change(view_duration, previous_view_duration) + .vr-analytics-tooltips{ data: { toggle: "tooltip", placement: "right" }, title: tooltip_content(view_duration, previous_view_duration, params[:interval], @start_date, @end_date) } + ? + + .col.col-12.col-sm-4.d-flex.align-items-center.mb-3.mb-sm-0 + .vr-analytics-count.ml-0 + Avg. view percentage + - view_percent = avg_view_percentage(@video_view_events) + - previous_view_percent = avg_view_percentage(@previous_period_video_view_events) + = "#{view_percent.round(2)}%" + .vr-analytics-percent-change + = display_percent_change(view_percent, previous_view_percent) + .vr-analytics-tooltips{ data: { toggle: "tooltip", placement: "right" }, title: tooltip_content(view_percent, previous_view_percent, params[:interval], @start_date, @end_date) } + ? + - if @video_view_events.present? + .vr-analytics-section-body + .vr-analytics-event-label + - top_videos = top_three_videos(@video_view_events, @previous_period_video_view_events) + = "Top #{top_videos.count} videos" + + .row.mt-4 + - top_videos.each do |event| + .col.col-12.col-lg-5.mb-4 + .vr-analytics-card + = link_to '', dashboard_events_path(ahoy_event_type: event[:name]) + .vr-analytics-card-section + .d-flex + .vr-analytics-card-resource-poster-conatiner + %img{src: event[:resource_image]} + .vr-analytics-card-resource-title-duration + .vr-analytics-card-resource-title + = link_to event[:resource_title], api_namespace_resource_path(api_namespace_id: event[:namespace_id], id: event[:resource_id]) + .vr-analytics-card-resource-duration + = Time.at(event[:duration]/1000).strftime("%M:%S") + + %hr + .vr-analytics-card-section + .row + .col.col-6 + .d-flex.align-items-center + .vr-analytics-count-lg + = to_minutes(event[:total_watch_time]) + .vr-analytics-percent-change + = display_percent_change(event[:total_watch_time], event[:previous_period_total_watch_time]) + .vr-analytics-event-label.mt-2 + Total watch time + .col.col-6 + .d-flex.align-items-center + .vr-analytics-count-lg + = event[:total_views] + .vr-analytics-percent-change + = display_percent_change(event[:total_views], event[:previous_period_total_views]) + .vr-analytics-event-label.mt-2 + Total views + - else + .vr-analytics-blank-states + There are no video view events within the selected date range. + %br + Use 'data-violet-track-video-view="true"' attribute on a video element to track views. + %br + %br + %code + :escaped + + + %br + %br + + Please make sure 'data-violet-resource-id' is present and Analytics mapping is populated + + %hr.m-0 + = render partial: 'events', locals: { events: @form_submit_events, previous_period_events: @previous_period_form_submit_events, title: 'Form Submissions', type: 'submitables' } + - unless @form_submit_events.present? + .vr-analytics-blank-states + There are no form submission events within the selected date range. + %br + Use 'data-violet-track-from-submit="true"' attribute on a form element to track form submissions. + %br + %br + %code + :escaped + {{ cms:helper render_form, 1, { data: { violet-track-form-submit: true }}}} + %br + %br + Default event name withh me \#{api_namespace.slug}-form-submit and default event label will be \#{api_namespace.slug} Form Submit, which can be overridden with 'data-violet-event-name' and 'data-violet-event-label' respectively. + %br + %br + OR + %br + %br + %code + :escaped +
+ %br + :escaped + . + %br + :escaped + . + %br + :escaped +
+ + %hr.m-0 + = render partial: 'events', locals: { events: @section_view_events, previous_period_events: @previous_period_section_view_events, title: 'Section Views', type: 'viewables' } + - unless @section_view_events.present? + .vr-analytics-blank-states + There are no section view events within the selected date range. + %br + Use 'data-violet-track-section-view="true"' attribute on a HTML element to track section views. + %br + %br + %code + :escaped +
+ %br + :escaped + . + %br + :escaped + . + %br + :escaped +
+ + %br + %br + Make sure to adjust target visibility threshold for large sections with 'data-violet-visibility-threshold'. + %br + Threshold indicates at what percentage of the target's visibility the tracking should be executed + %br + %a{href: 'https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#thresholds'} + https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#thresholds + + %br + %br + By default, the threshold value is 0.75 + + %hr.m-0 + = render partial: 'events', locals: { events: @legacy_and_system_events, previous_period_events: @previous_period_legacy_and_system_events, title: 'Events', type: 'events' } diff --git a/app/views/layouts/comfy/admin/cms.html.haml b/app/views/layouts/comfy/admin/cms.html.haml index 500f666b1..c68dc7623 100644 --- a/app/views/layouts/comfy/admin/cms.html.haml +++ b/app/views/layouts/comfy/admin/cms.html.haml @@ -9,7 +9,7 @@ -# this is a hack until I figure out how to get chartkick working without importing the whole application js pack - if controller_name == 'dashboard' = javascript_pack_tag 'application', 'data-turbo-track': 'reload' - + = javascript_include_tag 'comfy/admin/cms/dashboard' = render 'layouts/comfy/admin/cms/head' diff --git a/app/views/layouts/comfy/admin/cms/_body.html.haml b/app/views/layouts/comfy/admin/cms/_body.html.haml index a5c9a7ac8..1c9ac9150 100644 --- a/app/views/layouts/comfy/admin/cms/_body.html.haml +++ b/app/views/layouts/comfy/admin/cms/_body.html.haml @@ -3,11 +3,12 @@ .row #cms-left.col-lg-2.bg-dark = render "layouts/comfy/admin/cms/left" - #cms-main.col-lg-8.m-auto + #cms-main{class: "col-lg-#{controller_name == 'dashboard' ? '10 ml-auto' : '8 m-auto'}"} = render "layouts/comfy/admin/cms/flash" = yield = render 'comfy/admin/cms/files/modal' if @site && !@site.new_record? - #cms-right.col-lg-2 - = render 'layouts/comfy/admin/cms/right' + - unless controller_name == 'dashboard' + #cms-right.col-lg-2 + = render 'layouts/comfy/admin/cms/right' = render "layouts/comfy/admin/cms/footer_js" \ No newline at end of file diff --git a/app/views/layouts/website.html.erb b/app/views/layouts/website.html.erb index 5a1298b10..41879f7dc 100644 --- a/app/views/layouts/website.html.erb +++ b/app/views/layouts/website.html.erb @@ -8,6 +8,7 @@ <%= stylesheet_link_tag 'application', media: 'all', 'data-turbo-track': 'reload' %> <%= javascript_pack_tag 'application', 'data-turbo-track': 'reload' %> + <%= javascript_pack_tag 'violet_analytics', 'data-turbo-track': 'reload' %> <% if @cms_site && @cms_layout %> @@ -20,7 +21,7 @@ <%= render partial: 'shared/flash' %> <%= render partial: 'shared/navbars/web_admin' %> - + <%= yield %> <%= render_cookies_consent_ui %> diff --git a/config/initializers/comfortable_mexican_sofa.rb b/config/initializers/comfortable_mexican_sofa.rb index d9923dc34..f331bcb19 100644 --- a/config/initializers/comfortable_mexican_sofa.rb +++ b/config/initializers/comfortable_mexican_sofa.rb @@ -17,7 +17,7 @@ def authenticate visit_id = current_visit ? current_visit.id : nil ahoy.track( "comfy-cms-page-visit", - { visit_id: visit_id, page_id: @cms_page&.id, user_id: user_id } + { visit_id: visit_id, page_id: @cms_page&.id, user_id: user_id, category: Ahoy::Event::EVENT_CATEGORIES[:page_visit] } ) end protected_paths = Comfy::Cms::Page.where(is_restricted: true).pluck(:full_path) diff --git a/config/routes.rb b/config/routes.rb index de422f2e6..235ed2dc5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,6 +20,8 @@ def self.matches?(request) post 'import_api_namespace', to: 'comfy/admin/api_namespaces#import_as_json', as: :import_as_json_api_namespaces + get 'v2/dashboard', to: 'comfy/admin/v2/dashboard#dashboard' + resources :signup_wizard resources :signin_wizard constraints SubdomainConstraint do @@ -62,6 +64,7 @@ def self.matches?(request) get 'export_with_associations_as_json' get 'export_without_associations_as_json' patch 'social_share_metadata' + patch 'analytics_metadata' patch 'api_action_workflow' end diff --git a/db/migrate/20230124014921_add_analytics_metadata_to_api_namespace.rb b/db/migrate/20230124014921_add_analytics_metadata_to_api_namespace.rb new file mode 100644 index 000000000..54280cd79 --- /dev/null +++ b/db/migrate/20230124014921_add_analytics_metadata_to_api_namespace.rb @@ -0,0 +1,5 @@ +class AddAnalyticsMetadataToApiNamespace < ActiveRecord::Migration[6.1] + def change + add_column :api_namespaces, :analytics_metadata, :jsonb + end +end diff --git a/db/schema.rb b/db/schema.rb index 07333326f..6b2ef576c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_01_09_150520) do +ActiveRecord::Schema.define(version: 2023_01_24_014921) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -189,6 +189,7 @@ t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.jsonb "social_share_metadata" + t.jsonb "analytics_metadata" t.index ["properties"], name: "index_api_namespaces_on_properties", opclass: :jsonb_path_ops, using: :gin end diff --git a/package.json b/package.json index 2d189379a..b2e4c1d56 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ "@rails/webpacker": "5.2.1", "ahoy.js": "^0.3.8", "bootstrap": "^4.6.0", + "bootstrap-daterangepicker": "^3.1.0", "chart.js": "^3.3.0", "chartkick": "^4.0.4", "jquery": "^3.6.0", + "moment": "^2.29.4", "popper.js": "^1.16.1", "rails-erb-loader": "^5.5.2", "select2": "^4.1.0-rc.0", diff --git a/test/controllers/admin/comfy/api_namespaces_controller_test.rb b/test/controllers/admin/comfy/api_namespaces_controller_test.rb index 1a81b5139..5cb35af06 100755 --- a/test/controllers/admin/comfy/api_namespaces_controller_test.rb +++ b/test/controllers/admin/comfy/api_namespaces_controller_test.rb @@ -243,7 +243,7 @@ class Comfy::Admin::ApiNamespacesControllerTest < ActionDispatch::IntegrationTes stubbed_date = DateTime.new(2022, 1, 1) DateTime.stubs(:now).returns(stubbed_date) get export_api_namespace_url(api_namespace, format: :csv) - expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,#{api_namespace.social_share_metadata}\n" + expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,#{api_namespace.social_share_metadata}\nanalytics_metadata,#{api_namespace.analytics_metadata}\n" assert_response :success assert_equal response.body, expected_csv assert_equal response.header['Content-Disposition'], "attachment; filename=api_namespace_#{api_namespace.id}_#{DateTime.now.to_i}.csv" @@ -2285,7 +2285,7 @@ class Comfy::Admin::ApiNamespacesControllerTest < ActionDispatch::IntegrationTes stubbed_date = DateTime.new(2022, 1, 1) DateTime.stubs(:now).returns(stubbed_date) get export_api_namespace_url(api_namespace, format: :csv) - expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\n" + expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\nanalytics_metadata,\n" assert_response :success assert_equal response.body, expected_csv assert_equal response.header['Content-Disposition'], "attachment; filename=api_namespace_#{api_namespace.id}_#{DateTime.now.to_i}.csv" @@ -2299,7 +2299,7 @@ class Comfy::Admin::ApiNamespacesControllerTest < ActionDispatch::IntegrationTes stubbed_date = DateTime.new(2022, 1, 1) DateTime.stubs(:now).returns(stubbed_date) get export_api_namespace_url(api_namespace, format: :csv) - expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\n" + expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\nanalytics_metadata,\n" assert_response :success assert_equal response.body, expected_csv assert_equal response.header['Content-Disposition'], "attachment; filename=api_namespace_#{api_namespace.id}_#{DateTime.now.to_i}.csv" @@ -2313,7 +2313,7 @@ class Comfy::Admin::ApiNamespacesControllerTest < ActionDispatch::IntegrationTes stubbed_date = DateTime.new(2022, 1, 1) DateTime.stubs(:now).returns(stubbed_date) get export_api_namespace_url(api_namespace, format: :csv) - expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\n" + expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\nanalytics_metadata,\n" assert_response :success assert_equal response.body, expected_csv assert_equal response.header['Content-Disposition'], "attachment; filename=api_namespace_#{api_namespace.id}_#{DateTime.now.to_i}.csv" @@ -2344,7 +2344,7 @@ class Comfy::Admin::ApiNamespacesControllerTest < ActionDispatch::IntegrationTes stubbed_date = DateTime.new(2022, 1, 1) DateTime.stubs(:now).returns(stubbed_date) get export_api_namespace_url(api_namespace, format: :csv) - expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\n" + expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\nanalytics_metadata,\n" assert_response :success assert_equal response.body, expected_csv assert_equal response.header['Content-Disposition'], "attachment; filename=api_namespace_#{api_namespace.id}_#{DateTime.now.to_i}.csv" @@ -2360,7 +2360,7 @@ class Comfy::Admin::ApiNamespacesControllerTest < ActionDispatch::IntegrationTes stubbed_date = DateTime.new(2022, 1, 1) DateTime.stubs(:now).returns(stubbed_date) get export_api_namespace_url(api_namespace, format: :csv) - expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\n" + expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\nanalytics_metadata,\n" assert_response :success assert_equal response.body, expected_csv assert_equal response.header['Content-Disposition'], "attachment; filename=api_namespace_#{api_namespace.id}_#{DateTime.now.to_i}.csv" @@ -2376,7 +2376,7 @@ class Comfy::Admin::ApiNamespacesControllerTest < ActionDispatch::IntegrationTes stubbed_date = DateTime.new(2022, 1, 1) DateTime.stubs(:now).returns(stubbed_date) get export_api_namespace_url(api_namespace, format: :csv) - expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\n" + expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\nanalytics_metadata,\n" assert_response :success assert_equal response.body, expected_csv assert_equal response.header['Content-Disposition'], "attachment; filename=api_namespace_#{api_namespace.id}_#{DateTime.now.to_i}.csv" @@ -2390,7 +2390,7 @@ class Comfy::Admin::ApiNamespacesControllerTest < ActionDispatch::IntegrationTes stubbed_date = DateTime.new(2022, 1, 1) DateTime.stubs(:now).returns(stubbed_date) get export_api_namespace_url(api_namespace, format: :csv) - expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\n" + expected_csv = "id,#{api_namespace.id}\nname,namespace_with_all_types\nslug,namespace_with_all_types\nversion,1\nnull,\narray,\"[\"\"yes\"\", \"\"no\"\"]\"\nnumber,123\nobject,\"{\"\"a\"\"=>\"\"b\"\", \"\"c\"\"=>\"\"d\"\"}\"\nstring,string\nboolean,true\nrequires_authentication,false\nnamespace_type,create-read-update-delete\ncreated_at,#{api_namespace.created_at}\nupdated_at,#{api_namespace.updated_at}\nsocial_share_metadata,\nanalytics_metadata,\n" assert_response :success assert_equal response.body, expected_csv assert_equal response.header['Content-Disposition'], "attachment; filename=api_namespace_#{api_namespace.id}_#{DateTime.now.to_i}.csv" @@ -3029,4 +3029,31 @@ class Comfy::Admin::ApiNamespacesControllerTest < ActionDispatch::IntegrationTes end ######## API Accessibility Tests - END ######### + + # ANALYTICS METADATA + test 'analytics_metadata# should return success when the user has authority to manage analytics' do + @user.update!(can_manage_analytics: true) + payload = {api_namespace: {"analytics_metadata"=>{"title"=>"Array", "author"=>"String", "thumbnail"=>"picto"}}} + sign_in(@user) + + patch analytics_metadata_api_namespace_url(@api_namespace), params: payload + assert_response :redirect + + expected_message = 'Analytics Metadata successfully updated.' + assert_equal expected_message, flash[:notice] + assert_equal payload[:api_namespace]['analytics_metadata'], @api_namespace.reload.analytics_metadata + end + + test 'analytics_metadata# should return error when the user has no authority to manage analytics' do + @user.update!(can_manage_analytics: false) + payload = {api_namespace: {"analytics_metadata"=>{"title"=>"Array", "author"=>"String", "thumbnail"=>"picto"}}} + sign_in(@user) + + patch analytics_metadata_api_namespace_url(@api_namespace), params: payload + assert_response :redirect + + expected_message = 'You do not have the permission to do that. Only users who can_manage_analytics are allowed to perform that action.' + assert_equal expected_message, flash[:alert] + refute_equal payload[:api_namespace]['analytics_metadata'], @api_namespace.reload.analytics_metadata + end end diff --git a/test/controllers/admin/comfy/v2/dashboard_controller_test.rb b/test/controllers/admin/comfy/v2/dashboard_controller_test.rb new file mode 100644 index 000000000..8890699b1 --- /dev/null +++ b/test/controllers/admin/comfy/v2/dashboard_controller_test.rb @@ -0,0 +1,96 @@ +require "test_helper" + +class Comfy::Admin::V2::DashboardControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:public) + @subdomain = subdomains(:public) + @user.update(can_manage_analytics: true) + @page = comfy_cms_pages(:root) + site = Comfy::Cms::Site.first + layout = site.layouts.last + @page_2 = layout.pages.create( + site_id: site.id, + label: 'test-cms-page', + slug: 'test-cms-page', + ) + @api_resource = api_resources(:one) + end + + test "should deny #v2_dashboard if not signed in" do + get v2_dashboard_url + assert_redirected_to new_user_session_url + end + + test "should deny #v2_dashboard if not permissioned" do + sign_in(@user) + @user.update(can_manage_analytics: false) + get v2_dashboard_url + assert_response :redirect + end + + test "should get #v2_dashboard if signed in and permissioned" do + sign_in(@user) + get v2_dashboard_url + assert_response :success + end + + test "#dashboard: should set correct data" do + @subdomain.update!(tracking_enabled: true) + visit = Ahoy::Visit.first + page_visit_event_1 = visit.events.create(name: 'comfy-cms-page-visit', user_id: @user.id, time: (Time.now.beginning_of_month - 4.days), properties: {"label"=>"test_page_view", "page_id"=>@page.id, "category"=>"page_visit", "page_title"=>"lvh.me:5250"}) + click_event_1 = visit.events.create(name: 'test-link-click', user_id: @user.id, time: (Time.now.beginning_of_month - 4.days), properties: {"tag"=>"BUTTON", "label"=>"test link", "page_id"=>@page.id, "category"=>"click"}) + video_watch_event_1 = visit.events.create(name: 'test-video-watched', user_id: @user.id, time: (Time.now.beginning_of_month - 4.days), properties: {"label"=>"watched_this_video", "page_id"=>@page.id, "category"=>"video_view", "watch_time"=>11891, "resource_id"=>@api_resource.id, "video_start"=>true, "total_duration"=>11944.444}) + + page_visit_event_1_page_2 = visit.events.create(name: 'comfy-cms-page-visit', user_id: @user.id, time: Time.now, properties: {"label"=>"test_page_view", "page_id"=>@page_2.id, "category"=>"page_visit", "page_title"=>"lvh.me:5250"}) + click_event_1_page_2 = visit.events.create(name: 'test-link-click', user_id: @user.id, time: Time.now, properties: {"tag"=>"BUTTON", "label"=>"test link", "page_id"=>@page_2.id, "category"=>"click"}) + video_watch_event_1_page_2 = visit.events.create(name: 'test-video-watched', user_id: @user.id, time: Time.now, properties: {"label"=>"watched_this_video", "page_id"=>@page_2.id, "category"=>"video_view", "watch_time"=>11891, "resource_id"=>@api_resource.id, "video_start"=>true, "total_duration"=>11944.444}) + + visit_1 = visit.dup + visit_1.save! + page_visit_event_2 = visit.events.create(name: 'comfy-cms-page-visit', user_id: @user.id, time: (Time.now.beginning_of_month - 4.months), properties: {"label"=>"test_page_view", "page_id"=>@page_2.id, "category"=>"page_visit", "page_title"=>"lvh.me:5250"}) + click_event_2 = visit.events.create(name: 'test-link-click', user_id: @user.id, time: (Time.now.beginning_of_month - 4.months), properties: {"tag"=>"BUTTON", "label"=>"test link", "page_id"=>@page.id, "category"=>"click"}) + video_watch_event_2 = visit.events.create(name: 'test-video-watched', user_id: @user.id, time: (Time.now.beginning_of_month - 4.months), properties: {"label"=>"watched_this_video", "page_id"=>@page.id, "category"=>"video_view", "watch_time"=>11891, "resource_id"=>2, "video_start"=>true, "total_duration"=>11944.444}) + + @user.update(can_manage_analytics: true) + sign_in(@user) + + get v2_dashboard_url + + # default range should be current month and previous period should be last month + assert_equal Date.today.beginning_of_month, assigns(:start_date) + assert_equal Date.today.end_of_month, assigns(:end_date) + + assert_equal [page_visit_event_1_page_2.id], assigns(:page_visit_events).pluck(:id) + assert_equal [click_event_1_page_2.id], assigns(:click_events).pluck(:id) + assert_equal [video_watch_event_1_page_2.id], assigns(:video_view_events).pluck(:id) + + assert_equal [page_visit_event_1.id], assigns(:previous_period_page_visit_events).pluck(:id) + assert_equal [click_event_1.id], assigns(:previous_period_click_events).pluck(:id) + assert_equal [video_watch_event_1.id], assigns(:previous_period_video_view_events).pluck(:id) + + # When range params is present + get v2_dashboard_url, params: {start_date: (Time.now.beginning_of_month - 2.months).strftime('%Y-%m-%d'), end_date: Time.now.end_of_month.strftime('%Y-%m-%d'), interval: "3 months" } + + assert_equal (Time.now.beginning_of_month - 2.months).to_date, assigns(:start_date) + assert_equal Time.now.end_of_month.to_date, assigns(:end_date) + + assert_equal [page_visit_event_1.id, page_visit_event_1_page_2.id].sort, assigns(:page_visit_events).pluck(:id).sort + assert_equal [click_event_1.id, click_event_1_page_2.id].sort, assigns(:click_events).pluck(:id).sort + assert_equal [video_watch_event_1.id, video_watch_event_1_page_2.id].sort, assigns(:video_view_events).pluck(:id).sort + + assert_equal [page_visit_event_2.id], assigns(:previous_period_page_visit_events).pluck(:id) + assert_equal [click_event_2.id], assigns(:previous_period_click_events).pluck(:id) + assert_equal [video_watch_event_2.id], assigns(:previous_period_video_view_events).pluck(:id) + + # When page params present, it should filter by page + get v2_dashboard_url, params: {start_date: (Time.now.beginning_of_month - 2.months).strftime('%Y-%m-%d'), end_date: Time.now.end_of_month.strftime('%Y-%m-%d'), interval: "3 months", page: @page.id } + + assert_equal [page_visit_event_1.id], assigns(:page_visit_events).pluck(:id) + assert_equal [click_event_1.id], assigns(:click_events).pluck(:id) + assert_equal [video_watch_event_1.id], assigns(:video_view_events).pluck(:id) + + assert_empty assigns(:previous_period_page_visit_events) + assert_equal [click_event_2.id], assigns(:previous_period_click_events).pluck(:id) + assert_equal [video_watch_event_2.id], assigns(:previous_period_video_view_events).pluck(:id) + end +end diff --git a/test/helpers/dashboard_helper_test.rb b/test/helpers/dashboard_helper_test.rb index a3d5d8e51..1addc335f 100644 --- a/test/helpers/dashboard_helper_test.rb +++ b/test/helpers/dashboard_helper_test.rb @@ -11,4 +11,143 @@ class DashboardHelperTest < ActionView::TestCase assert_equal "private-system-url-redacted" , redact_private_urls(url_3) assert_equal url_non_redactable , redact_private_urls(url_non_redactable) end + + test 'page_visit_chart_data' do + travel_to Date.new(2023, 01, 26) do + visit = Ahoy::Visit.first + visit.update!(device_type: 'Desktop') + visit.events.create(name: 'test-page-view', user_id: 1, time: Time.now, properties: { + label: "test_page_view", + page_id: 1, + category: "page_visit", + }) + + visit.events.create(name: 'test-page-view', user_id: 1, time: 1.day.ago, properties: { + label: "test_page_view", + page_id: 1, + category: "page_visit", + }) + + visit.events.create(name: 'test-page-view', user_id: 1, time: 2.months.ago, properties: { + label: "test_page_view", + page_id: 1, + category: "page_visit", + }) + + visit.events.create(name: 'test-page-view', user_id: 1, time: 6.months.ago, properties: { + label: "test_page_view", + page_id: 1, + category: "page_visit", + }) + + visit.events.create(name: 'test-page-view', user_id: 1, time: 2.years.ago, properties: { + label: "test_page_view", + page_id: 1, + category: "page_visit", + }) + + # should split into days + assert_equal [{:name=>"Desktop", :data=>{"#{2.days.ago.strftime('%-d %^b %Y')}"=>0, "#{1.days.ago.strftime('%-d %^b %Y')}"=>1, "#{Time.now.strftime('%-d %^b %Y')}"=>1}}], page_visit_chart_data(Ahoy::Event.where(name: 'test-page-view').joins(:visit), 2.days.ago.to_date, Time.now.to_date) + + # should split into weeks + assert_equal [{:name=>"Desktop", :data=>{"#{4.weeks.ago.strftime('%V')}"=>0, "#{3.weeks.ago.strftime('%V')}"=>0, "#{2.weeks.ago.strftime('%V')}"=>0, "#{1.weeks.ago.strftime('%V')}"=>2, "#{Time.now.strftime('%V')}"=>0}}], page_visit_chart_data(Ahoy::Event.where(name: 'test-page-view').joins(:visit), Time.now.beginning_of_month.to_date, Time.now.end_of_month.to_date) + + # should split into months + assert_equal [{:name=>"Desktop", :data=>{"#{2.months.ago.strftime('%^b %Y')}"=>1, "#{1.months.ago.strftime('%^b %Y')}"=>0, "#{Time.now.strftime('%^b %Y')}"=>2}}], page_visit_chart_data(Ahoy::Event.where(name: 'test-page-view').joins(:visit), 2.months.ago.to_date, Time.now.to_date) + + # should split into years + assert_equal [{:name=>"Desktop", :data=>{"#{2.years.ago.strftime('%Y')}"=>1, "#{1.years.ago.strftime('%Y')}"=>2, "#{Time.now.strftime('%Y')}"=>2}}], page_visit_chart_data(Ahoy::Event.where(name: 'test-page-view').joins(:visit), 2.years.ago.to_date, Time.now.to_date) + end + end + + test 'page_name' do + assert_equal 'Website', page_name(nil) + + page = comfy_cms_pages(:root) + assert_equal page.label, page_name(page.id) + end + + test 'display_percent_change' do + refute display_percent_change(100, 0) + + assert_equal "
20.0 %
", display_percent_change(60, 50) + + assert_equal "
16.67 %
", display_percent_change(50, 60) + end + + test 'tooltip_content' do + assert_equal "There's no data from the previous 3 months to compare", tooltip_content(1, 0, '3 months', (Time.now.beginning_of_month - 2.months).to_date, Time.now.end_of_month.to_date) + + assert_equal "This is a 100.0 % increase compared to the previous 6 months", tooltip_content(2, 1, '6 months', (Time.now.beginning_of_month - 5.months).to_date, Time.now.end_of_month.to_date) + + assert_equal "There's no data from the previous month to compare", tooltip_content(1, 0, Date.current.strftime('%B %Y'), Time.now.beginning_of_month.to_date, Time.now.end_of_month.to_date) + + assert_equal "This is a 50.0 % decrease compared to the previous month", tooltip_content(1, 2, Date.current.strftime('%B %Y'), Time.now.beginning_of_month.to_date, Time.now.end_of_month.to_date) + + assert_equal "There's no data from the previous 10 days to compare", tooltip_content(1, 0, 'Custom Interval', (Time.now - 10.days).to_date, Time.now.to_date) + + assert_equal "This is a 100.0 % increase compared to the previous 10 days", tooltip_content(2, 1, 'Custom Range', (Time.now - 10.days).to_date, Time.now.to_date) + end + + test 'total watch time' do + mock_video_view_events + assert_equal 30000, total_watch_time([@video_watch_event_1, @video_watch_event_2]) + end + + test 'to_minutes' do + assert_equal '10,000.0 min', to_minutes(10000*1000*60) + end + + test 'total_views' do + mock_video_view_events + assert_equal 2, total_views([@video_watch_event_1, @video_watch_event_2, @video_watch_event_3]) + end + + test 'avg_view_duration' do + assert_equal 0, avg_view_duration([]) + mock_video_view_events + assert_equal 25000.0, avg_view_duration([@video_watch_event_1, @video_watch_event_2, @video_watch_event_3]) + end + + test 'avg_view_percentange' do + mock_video_view_events + assert_equal 50.0, avg_view_percentage([@video_watch_event_1, @video_watch_event_2, @video_watch_event_3]) + end + + private + + def mock_video_view_events + api_resource = api_resources(:one) + api_resource_2 = api_resources(:two) + visit = Ahoy::Visit.first + @video_watch_event_1 = visit.events.create(name: 'test-video-watched', user_id: 1, time: Time.now, properties: { + label: "watched_this_video", + page_id: 1, + category: "video_view", + watch_time: 10000, + resource_id: api_resource.id, + video_start: true, + total_duration: 40000 + }) + + @video_watch_event_2 = visit.events.create(name: 'test-video-watched', user_id: 1, time: Time.now, properties: { + label: "watched_this_video", + category: "video_view", + page_id: 1, + watch_time: 20000, + resource_id: api_resource_2.id, + video_start: true, + total_duration: 80000 + }) + + @video_watch_event_3 = visit.events.create(name: 'test-video-watched', user_id: 1, time: Time.now, properties: { + label: "watched_this_video", + category: "video_view", + page_id: 1, + watch_time: 20000, + resource_id: api_resource.id, + video_start: false, + total_duration: 40000 + }) + end end \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 022a40ed4..0aff33050 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1567,6 +1567,14 @@ boolbase@^1.0.0, boolbase@~1.0.0: resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= +bootstrap-daterangepicker@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/bootstrap-daterangepicker/-/bootstrap-daterangepicker-3.1.0.tgz#632e6fb2de4b6360c5c0a9d5f6adb9aace051fe8" + integrity sha512-oaQZx6ZBDo/dZNyXGVi2rx5GmFXThyQLAxdtIqjtLlYVaQUfQALl5JZMJJZzyDIX7blfy4ppZPAJ10g8Ma4d/g== + dependencies: + jquery ">=1.10" + moment "^2.9.0" + bootstrap@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.6.0.tgz#97b9f29ac98f98dfa43bf7468262d84392552fd7" @@ -4121,6 +4129,11 @@ jest-worker@^26.5.0: merge-stream "^2.0.0" supports-color "^7.0.0" +jquery@>=1.10: + version "3.6.3" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.3.tgz#23ed2ffed8a19e048814f13391a19afcdba160e6" + integrity sha512-bZ5Sy3YzKo9Fyc8wH2iIQK4JImJ6R0GWI9kL1/k7Z91ZBNgkRXE6U0JfHIizZbort8ZunhSI3jw9I6253ahKfg== + jquery@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470" @@ -4667,6 +4680,11 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +moment@^2.29.4, moment@^2.9.0: + version "2.29.4" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" + integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== + move-concurrently@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"