Upgrade heatmaps stile Hotjar: movimenti mouse e anteprima pagina live.
Traccia i movimenti aggregati, overlay a gradienti sull’iframe della pagina e toggle click/move in admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<tr>
|
||||
<th><%= t("admin.analytics.index.table.path") %></th>
|
||||
<th><%= t("admin.analytics.index.table.pageviews") %></th>
|
||||
<th><%= t("admin.analytics.index.table.moves") %></th>
|
||||
<th><%= t("admin.analytics.index.table.clicks") %></th>
|
||||
<th><%= t("admin.analytics.index.table.avg_scroll") %></th>
|
||||
<th><%= t("admin.analytics.index.table.max_scroll") %></th>
|
||||
@@ -52,6 +53,7 @@
|
||||
<tr>
|
||||
<td><code class="admin-mono"><%= row[:page_path] %></code></td>
|
||||
<td><%= row[:pageviews] %></td>
|
||||
<td><%= row[:moves] %></td>
|
||||
<td><%= row[:clicks] %></td>
|
||||
<td class="muted"><%= avg %>%</td>
|
||||
<td class="muted"><%= row[:max_scroll] %>%</td>
|
||||
|
||||
@@ -30,6 +30,17 @@
|
||||
@filters[:device]
|
||||
) %>
|
||||
</label>
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.analytics.filters.layer") %></span>
|
||||
<%= select_tag :layer,
|
||||
options_for_select(
|
||||
[
|
||||
[t("admin.analytics.filters.layer_move"), "move"],
|
||||
[t("admin.analytics.filters.layer_click"), "click"]
|
||||
],
|
||||
@filters[:layer]
|
||||
) %>
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-filter-actions">
|
||||
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||
@@ -45,9 +56,13 @@
|
||||
<dt><%= t("admin.analytics.index.table.pageviews") %></dt>
|
||||
<dd><%= @pageviews %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.analytics.index.table.moves") %></dt>
|
||||
<dd><%= @move_total %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.analytics.index.table.clicks") %></dt>
|
||||
<dd><%= @cells.values.sum %></dd>
|
||||
<dd><%= @click_total %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.analytics.index.table.avg_scroll") %></dt>
|
||||
@@ -73,43 +88,35 @@
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h3><%= t("admin.analytics.show.heatmap_title") %></h3>
|
||||
<h3>
|
||||
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
|
||||
</h3>
|
||||
<p class="muted admin-table-sub"><%= t("admin.analytics.show.heatmap_hint") %></p>
|
||||
|
||||
<% if @cells.any? %>
|
||||
<div class="admin-heatmap-wrap">
|
||||
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
|
||||
<% if @preview_url.present? %>
|
||||
<iframe
|
||||
class="admin-heatmap-frame"
|
||||
src="<%= @preview_url %>"
|
||||
title="<%= t("admin.analytics.show.preview_title") %>"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
></iframe>
|
||||
<% else %>
|
||||
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
|
||||
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.preview_unavailable") %></p>
|
||||
<% end %>
|
||||
<canvas
|
||||
id="admin-heatmap"
|
||||
class="admin-heatmap"
|
||||
width="800"
|
||||
height="1200"
|
||||
class="admin-heatmap-overlay"
|
||||
data-grid="<%= @grid %>"
|
||||
data-max="<%= @max_clicks %>"
|
||||
data-max="<%= [@max_weight, 1].max %>"
|
||||
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
|
||||
></canvas>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var canvas = document.getElementById("admin-heatmap");
|
||||
if (!canvas) return;
|
||||
var grid = parseInt(canvas.getAttribute("data-grid"), 10) || 40;
|
||||
var max = parseInt(canvas.getAttribute("data-max"), 10) || 1;
|
||||
var cells = [];
|
||||
try { cells = JSON.parse(canvas.getAttribute("data-cells") || "[]"); } catch (e) { cells = []; }
|
||||
var ctx = canvas.getContext("2d");
|
||||
var cw = canvas.width;
|
||||
var ch = canvas.height;
|
||||
ctx.fillStyle = "#0d0d12";
|
||||
ctx.fillRect(0, 0, cw, ch);
|
||||
var cellW = cw / grid;
|
||||
var cellH = ch / grid;
|
||||
cells.forEach(function (cell) {
|
||||
var intensity = Math.min(1, cell.c / max);
|
||||
var alpha = 0.15 + intensity * 0.75;
|
||||
ctx.fillStyle = "rgba(229, 57, 53, " + alpha + ")";
|
||||
ctx.fillRect(cell.x * cellW, cell.y * cellH, cellW + 0.5, cellH + 0.5);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script src="/admin-analytics-heatmap.js?v=1" defer></script>
|
||||
<% else %>
|
||||
<p class="empty"><%= t("admin.analytics.show.no_clicks") %></p>
|
||||
<p class="empty"><%= t("admin.analytics.show.no_points") %></p>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<script src="/password-toggle.js?v=2" defer></script>
|
||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||
<script src="/confirm-forms.js?v=7" defer></script>
|
||||
<script src="/site-analytics.js?v=1" defer></script>
|
||||
<script src="/site-analytics.js?v=2" defer></script>
|
||||
<script src="/cookie-consent.js?v=2" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<%= render "shared/marketing_footer" %>
|
||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||
<script src="/confirm-forms.js?v=7" defer></script>
|
||||
<script src="/site-analytics.js?v=1" defer></script>
|
||||
<script src="/site-analytics.js?v=2" defer></script>
|
||||
<script src="/cookie-consent.js?v=2" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user