diff --git a/backend/app/controllers/admin/analytics_controller.rb b/backend/app/controllers/admin/analytics_controller.rb index 408c40f..23db51e 100644 --- a/backend/app/controllers/admin/analytics_controller.rb +++ b/backend/app/controllers/admin/analytics_controller.rb @@ -12,27 +12,29 @@ module Admin scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to]) scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) - click_scope = AnalyticsPageCell.where(day: @filters[:from]..@filters[:to]) - click_scope = click_scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) + cell_scope = AnalyticsPageCell.where(day: @filters[:from]..@filters[:to]) + cell_scope = cell_scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) - clicks_by_path = click_scope.group(:page_path).sum(:click_count) + clicks_by_path = cell_scope.group(:page_path).sum(:click_count) + moves_by_path = cell_scope.group(:page_path).sum(:move_count) pageviews_by_path = scope.group(:page_path).sum(:pageview_count) scroll_samples_by_path = scope.group(:page_path).sum(:scroll_samples) scroll_sum_by_path = scope.group(:page_path).sum(:scroll_sum_pct) max_scroll_by_path = scope.group(:page_path).maximum(:max_scroll_pct) - paths = (pageviews_by_path.keys + clicks_by_path.keys).uniq + paths = (pageviews_by_path.keys + clicks_by_path.keys + moves_by_path.keys).uniq @pages = paths.map do |path| samples = scroll_samples_by_path[path].to_i { page_path: path, pageviews: pageviews_by_path[path].to_i, clicks: clicks_by_path[path].to_i, + moves: moves_by_path[path].to_i, scroll_samples: samples, scroll_sum: scroll_sum_by_path[path].to_i, max_scroll: max_scroll_by_path[path].to_i } - end.sort_by { |r| [-r[:pageviews], -r[:clicks], r[:page_path]] } + end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] } end def show @@ -42,13 +44,28 @@ module Admin @filters = { from: parse_date(params[:from]) || 7.days.ago.to_date, to: parse_date(params[:to]) || Time.zone.today, - device: params[:device].presence + device: params[:device].presence, + layer: params[:layer].to_s } cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) - @cells = cells.group(:cell_x, :cell_y).sum(:click_count) - @max_clicks = @cells.values.max.to_i + + @click_total = cells.sum(:click_count) + @move_total = cells.sum(:move_count) + @filters[:layer] = + if %w[move click].include?(@filters[:layer]) + @filters[:layer] + elsif @move_total.positive? + "move" + else + "click" + end + + counter = @filters[:layer] == "click" ? :click_count : :move_count + @cells = cells.group(:cell_x, :cell_y).sum(counter) + @max_weight = @cells.values.max.to_i + @total_points = @cells.values.sum stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) @@ -58,6 +75,7 @@ module Admin @max_scroll = stats.maximum(:max_scroll_pct).to_i @avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0 @grid = AnalyticsPageCell::GRID_SIZE + @preview_url = preview_url_for(@page_path) end private @@ -69,5 +87,12 @@ module Admin rescue ArgumentError, TypeError nil end + + def preview_url_for(path) + return nil if path.blank? + return nil if path.include?(":") + + "#{MatchLiveTv.app_public_url.chomp('/')}#{path}" + end end end diff --git a/backend/app/models/analytics_event.rb b/backend/app/models/analytics_event.rb index a1bde6c..3199660 100644 --- a/backend/app/models/analytics_event.rb +++ b/backend/app/models/analytics_event.rb @@ -3,11 +3,12 @@ class AnalyticsEvent < ApplicationRecord self.table_name = "analytics_events" - EVENT_TYPES = %w[click scroll pageview].freeze + EVENT_TYPES = %w[click scroll pageview move].freeze DEVICES = %w[mobile tablet desktop].freeze validates :page_path, presence: true validates :device, inclusion: { in: DEVICES } validates :event_type, inclusion: { in: EVENT_TYPES } validates :occurred_at, presence: true + validates :weight, numericality: { greater_than: 0, less_than_or_equal_to: 500 } end diff --git a/backend/app/models/analytics_page_cell.rb b/backend/app/models/analytics_page_cell.rb index f590204..fff2175 100644 --- a/backend/app/models/analytics_page_cell.rb +++ b/backend/app/models/analytics_page_cell.rb @@ -9,4 +9,5 @@ class AnalyticsPageCell < ApplicationRecord validates :device, inclusion: { in: AnalyticsEvent::DEVICES } validates :cell_x, :cell_y, inclusion: { in: 0...(GRID_SIZE) } validates :click_count, numericality: { greater_than_or_equal_to: 0 } + validates :move_count, numericality: { greater_than_or_equal_to: 0 } end diff --git a/backend/app/services/analytics/aggregate.rb b/backend/app/services/analytics/aggregate.rb index d768f49..86e9a30 100644 --- a/backend/app/services/analytics/aggregate.rb +++ b/backend/app/services/analytics/aggregate.rb @@ -10,14 +10,14 @@ module Analytics break if ids.empty? events = AnalyticsEvent.where(id: ids).to_a - apply_clicks(events.select { |e| e.event_type == "click" }) - apply_stats(events.reject { |e| e.event_type == "click" }) + apply_points(events.select { |e| e.event_type == "click" }, :click_count) + apply_points(events.select { |e| e.event_type == "move" }, :move_count) + apply_stats(events.select { |e| %w[pageview scroll].include?(e.event_type) }) AnalyticsEvent.where(id: ids).delete_all end end def purge_old!(retention: 30.days) - # Raw should already be empty after aggregate; keep as safety net. AnalyticsEvent.where("occurred_at < ?", retention.ago).delete_all AnalyticsPageCell.where("day < ?", retention.ago.to_date).delete_all AnalyticsPageStat.where("day < ?", retention.ago.to_date).delete_all @@ -25,7 +25,7 @@ module Analytics private - def apply_clicks(events) + def apply_points(events, counter_attr) return if events.empty? grid = AnalyticsPageCell::GRID_SIZE @@ -36,7 +36,7 @@ module Analytics cell_x = [[(e.x_pct.to_f / 100 * grid).floor, grid - 1].min, 0].max cell_y = [[(e.y_pct.to_f / 100 * grid).floor, grid - 1].min, 0].max key = [e.occurred_at.in_time_zone.to_date, e.page_path, e.device, cell_x, cell_y] - grouped[key] += 1 + grouped[key] += e.weight.to_i.clamp(1, 500) end now = Time.current @@ -44,7 +44,7 @@ module Analytics cell = AnalyticsPageCell.find_or_initialize_by( day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y ) - cell.click_count = cell.click_count.to_i + count + cell[counter_attr] = cell[counter_attr].to_i + count cell.created_at ||= now cell.updated_at = now cell.save! @@ -61,7 +61,7 @@ module Analytics bucket = grouped[key] ||= { pageviews: 0, scroll_samples: 0, scroll_sum: 0, max_scroll: 0 } case e.event_type when "pageview" - bucket[:pageviews] += 1 + bucket[:pageviews] += e.weight.to_i.clamp(1, 500) when "scroll" pct = e.scroll_pct.to_f.round bucket[:scroll_samples] += 1 diff --git a/backend/app/services/analytics/ingest.rb b/backend/app/services/analytics/ingest.rb index 3139f4f..7fe38d6 100644 --- a/backend/app/services/analytics/ingest.rb +++ b/backend/app/services/analytics/ingest.rb @@ -2,8 +2,8 @@ module Analytics class Ingest - MAX_BATCH = 50 - RATE_LIMIT_PER_MINUTE = 60 + MAX_BATCH = 80 + RATE_LIMIT_PER_MINUTE = 90 Result = Struct.new(:accepted, :rejected, :rate_limited, keyword_init: true) @@ -62,11 +62,17 @@ module Analytics return nil if page_path.length > 200 occurred_at = parse_time(data[:ts] || data[:occurred_at]) || Time.current + weight = data[:n].presence || data[:weight].presence || 1 + weight = Integer(weight) + weight = 1 if weight < 1 + weight = 500 if weight > 500 + attrs = { page_path: page_path, device: device, event_type: event_type, occurred_at: occurred_at, + weight: weight, x_pct: nil, y_pct: nil, scroll_pct: nil, @@ -75,7 +81,7 @@ module Analytics } case event_type - when "click" + when "click", "move" x = clamp_pct(data[:x] || data[:x_pct]) y = clamp_pct(data[:y] || data[:y_pct]) return nil if x.nil? || y.nil? @@ -92,6 +98,8 @@ module Analytics end attrs + rescue ArgumentError, TypeError + nil end def clamp_pct(value) diff --git a/backend/app/views/admin/analytics/index.html.erb b/backend/app/views/admin/analytics/index.html.erb index 043d5eb..ba91254 100644 --- a/backend/app/views/admin/analytics/index.html.erb +++ b/backend/app/views/admin/analytics/index.html.erb @@ -40,6 +40,7 @@
<%= row[:page_path] %><%= t("admin.analytics.show.heatmap_hint") %>
+ <% if @cells.any? %> -<%= t("admin.analytics.show.preview_unavailable") %>
+ <% end %><%= t("admin.analytics.show.no_clicks") %>
+<%= t("admin.analytics.show.no_points") %>
<% end %> diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index 62807eb..70e0e8e 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -26,7 +26,7 @@ - +