---
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
+
+
+ %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"