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 @@ <%= t("admin.analytics.index.table.path") %> <%= t("admin.analytics.index.table.pageviews") %> + <%= t("admin.analytics.index.table.moves") %> <%= t("admin.analytics.index.table.clicks") %> <%= t("admin.analytics.index.table.avg_scroll") %> <%= t("admin.analytics.index.table.max_scroll") %> @@ -52,6 +53,7 @@ <%= row[:page_path] %> <%= row[:pageviews] %> + <%= row[:moves] %> <%= row[:clicks] %> <%= avg %>% <%= row[:max_scroll] %>% diff --git a/backend/app/views/admin/analytics/show.html.erb b/backend/app/views/admin/analytics/show.html.erb index a3d7434..6facfac 100644 --- a/backend/app/views/admin/analytics/show.html.erb +++ b/backend/app/views/admin/analytics/show.html.erb @@ -30,6 +30,17 @@ @filters[:device] ) %> +
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %> @@ -45,9 +56,13 @@
<%= t("admin.analytics.index.table.pageviews") %>
<%= @pageviews %>
+
+
<%= t("admin.analytics.index.table.moves") %>
+
<%= @move_total %>
+
<%= t("admin.analytics.index.table.clicks") %>
-
<%= @cells.values.sum %>
+
<%= @click_total %>
<%= t("admin.analytics.index.table.avg_scroll") %>
@@ -73,43 +88,35 @@
-

<%= t("admin.analytics.show.heatmap_title") %>

+

+ <%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %> +

+

<%= t("admin.analytics.show.heatmap_hint") %>

+ <% if @cells.any? %> -
+
+ <% if @preview_url.present? %> + + <% else %> + +

<%= t("admin.analytics.show.preview_unavailable") %>

+ <% end %>
- + <% else %> -

<%= 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 @@ - + diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index e24f5c1..e29da34 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -21,7 +21,7 @@ <%= render "shared/marketing_footer" %> - + diff --git a/backend/config/locales/admin.de.yml b/backend/config/locales/admin.de.yml index aec4dda..c704d68 100644 --- a/backend/config/locales/admin.de.yml +++ b/backend/config/locales/admin.de.yml @@ -360,14 +360,18 @@ de: any: Alle apply: Filtern reset: Zurücksetzen + layer: Ebene + layer_move: Mausbewegungen + layer_click: Klicks index: title: Website-Analytics - lead: Aggregierte First-Party-Heatmaps und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten. + lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten. none: Keine Daten im gewählten Zeitraum. heatmap: Heatmap table: path: Seite pageviews: Pageviews + moves: Bewegungen clicks: Klicks avg_scroll: Scroll Ø max_scroll: Scroll max @@ -377,6 +381,12 @@ de: summary: Übersicht scroll_depth: Scrolltiefe scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%" + heatmap_moves: Mausbewegungs-Karte + heatmap_clicks: Klickkarte + heatmap_hint: Farbiges Overlay auf der Live-Seite (aktuelles Layout). Grün = wenig, Rot = viel. + preview_title: Seitenvorschau + preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id). Nur Overlay. + no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum. heatmap_title: Klickkarte no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum. diff --git a/backend/config/locales/admin.en.yml b/backend/config/locales/admin.en.yml index ea9d3ba..bbdcb1a 100644 --- a/backend/config/locales/admin.en.yml +++ b/backend/config/locales/admin.en.yml @@ -360,14 +360,18 @@ en: any: All apply: Filter reset: Reset + layer: Layer + layer_move: Mouse moves + layer_click: Clicks index: title: Site analytics - lead: Aggregated first-party heatmaps and scroll depth, only with analytics consent. No personal data. + lead: Aggregated first-party move/click heatmaps and scroll depth, only with analytics consent. No personal data. none: No data in the selected period. heatmap: Heatmap table: path: Page pageviews: Pageviews + moves: Moves clicks: Clicks avg_scroll: Avg scroll max_scroll: Max scroll @@ -377,6 +381,12 @@ en: summary: Summary scroll_depth: Scroll depth scroll_hint: "Average %{avg}% · max %{max}%" + heatmap_moves: Mouse-move map + heatmap_clicks: Click map + heatmap_hint: Colored overlay on the live page (current layout). Green = low, red = high. + preview_title: Page preview + preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id). Overlay only. + no_points: No aggregated points for this layer in the period. heatmap_title: Click map no_clicks: No aggregated clicks for this page in the period. diff --git a/backend/config/locales/admin.es.yml b/backend/config/locales/admin.es.yml index 5016206..99e948e 100644 --- a/backend/config/locales/admin.es.yml +++ b/backend/config/locales/admin.es.yml @@ -360,14 +360,18 @@ es: any: Todos apply: Filtrar reset: Restablecer + layer: Capa + layer_move: Movimientos del ratón + layer_click: Clics index: title: Analytics del sitio - lead: Heatmaps y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales. + lead: Heatmaps de movimientos/clics y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales. none: No hay datos en el periodo seleccionado. heatmap: Heatmap table: path: Página pageviews: Pageviews + moves: Movimientos clicks: Clics avg_scroll: Scroll medio max_scroll: Scroll máx @@ -377,6 +381,12 @@ es: summary: Resumen scroll_depth: Profundidad de scroll scroll_hint: "Media %{avg}% · máximo %{max}%" + heatmap_moves: Mapa de movimientos + heatmap_clicks: Mapa de clics + heatmap_hint: Superposición de color sobre la página en vivo (layout actual). Verde = poco, rojo = mucho. + preview_title: Vista previa de la página + preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id). Solo overlay. + no_points: No hay puntos agregados para esta capa en el periodo. heatmap_title: Mapa de clics no_clicks: No hay clics agregados para esta página en el periodo. diff --git a/backend/config/locales/admin.fr.yml b/backend/config/locales/admin.fr.yml index 4968a0b..bafb8bb 100644 --- a/backend/config/locales/admin.fr.yml +++ b/backend/config/locales/admin.fr.yml @@ -360,14 +360,18 @@ fr: any: Tous apply: Filtrer reset: Réinitialiser + layer: Couche + layer_move: Mouvements souris + layer_click: Clics index: title: Analytics du site - lead: Heatmaps et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle. + lead: Heatmaps mouvements/clics et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle. none: Aucune donnée sur la période sélectionnée. heatmap: Heatmap table: path: Page pageviews: Pages vues + moves: Mouvements clicks: Clics avg_scroll: Scroll moyen max_scroll: Scroll max @@ -377,6 +381,12 @@ fr: summary: Résumé scroll_depth: Profondeur de scroll scroll_hint: "Moyenne %{avg}% · maximum %{max}%" + heatmap_moves: Carte des mouvements + heatmap_clicks: Carte des clics + heatmap_hint: Superposition colorée sur la page live (mise en page actuelle). Vert = faible, rouge = fort. + preview_title: Aperçu de la page + preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id). Overlay seul. + no_points: Aucun point agrégé pour cette couche sur la période. heatmap_title: Carte des clics no_clicks: Aucun clic agrégé pour cette page sur la période. diff --git a/backend/config/locales/admin.it.yml b/backend/config/locales/admin.it.yml index 486856d..fabc558 100644 --- a/backend/config/locales/admin.it.yml +++ b/backend/config/locales/admin.it.yml @@ -381,14 +381,18 @@ it: any: Tutti apply: Filtra reset: Azzera + layer: Livello + layer_move: Movimenti mouse + layer_click: Click index: title: Analytics sito - lead: Heatmap e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale. + lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale. none: Nessun dato nel periodo selezionato. heatmap: Heatmap table: path: Pagina pageviews: Pageview + moves: Movimenti clicks: Click avg_scroll: Scroll medio max_scroll: Scroll max @@ -398,6 +402,12 @@ it: summary: Riepilogo scroll_depth: Profondità di scroll scroll_hint: "Media %{avg}% · massimo %{max}%" + heatmap_moves: Mappa movimenti mouse + heatmap_clicks: Mappa click + heatmap_hint: Overlay colorato sulla pagina live (layout attuale). Verde = poco, rosso = molto. + preview_title: Anteprima pagina + preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id). Mostra solo l’overlay. + no_points: Nessun punto aggregato per questo livello nel periodo. heatmap_title: Mappa click no_clicks: Nessun click aggregato per questa pagina nel periodo. diff --git a/backend/config/locales/legal.de.yml b/backend/config/locales/legal.de.yml index 113de3e..9f30033 100644 --- a/backend/config/locales/legal.de.yml +++ b/backend/config/locales/legal.de.yml @@ -88,7 +88,7 @@ de: s10_title: 10. Sicherheit s10_body: "Wir setzen angemessene technische und organisatorische Maßnahmen ein (authentifizierter Zugang, verschlüsselte Passwörter, HTTPS-Kommunikation, Trennung der Umgebungen, eingeschränkter Personalzugang). Kein System ist unverwundbar: Wenn Sie einen unbefugten Zugriff vermuten, ändern Sie Ihr Passwort und kontaktieren Sie uns." s11_title: 11. Cookies und ähnliche Technologien - s11_p1_html: "Die Website verwendet notwendige technische Cookies (Login-Sitzung, Sicherheit, Speicherung der Cookie-Präferenzen) und, vorbehaltlich der Einwilligung über das Banner, Google Analytics und aggregierte First-Party-Statistiken (Klick-/Scroll-Heatmaps) zur Produktverbesserung." + s11_p1_html: "Die Website verwendet notwendige technische Cookies (Login-Sitzung, Sicherheit, Speicherung der Cookie-Präferenzen) und, vorbehaltlich der Einwilligung über das Banner, Google Analytics und aggregierte First-Party-Statistiken (Mausbewegungs-/Klick-/Scroll-Heatmaps) zur Produktverbesserung." s11_p2_html: "Sie können Ihre Präferenzen jederzeit über den Link „Cookies verwalten“ in der Fußzeile ändern oder die %{cookie_policy_link} einsehen." s11_cookie_policy_link_text: Cookie-Richtlinie s12_title: 12. Änderungen @@ -169,7 +169,7 @@ de: s4_1_row2_duration: 12 Monate s4_1_row2_provider: Match Live TV (First-Party) s4_2_title: 4.2 Analytics (nur mit Ihrer Einwilligung) - s4_2_p1_html: "Wenn Sie „Alle Cookies“ akzeptieren oder die Statistiken im Banner aktivieren, laden wir Google Analytics 4, um zu verstehen, wie die Website genutzt wird (besuchte Seiten, aggregierte Herkunft, Gerät). Die Daten werden von Google Ireland Limited / Google LLC gemäß deren Richtlinien verarbeitet. Mit derselben Einwilligung erfassen wir außerdem aggregierte First-Party-Statistiken (Klicks und Scrolltiefe, ohne Nutzeridentifikation) für interne Heatmaps zur Produktverbesserung und Plattform-Marketing. Wir tracken weder Admin-Bereich, Regie, Live-Player noch Replay-Seiten." + s4_2_p1_html: "Wenn Sie „Alle Cookies“ akzeptieren oder die Statistiken im Banner aktivieren, laden wir Google Analytics 4, um zu verstehen, wie die Website genutzt wird (besuchte Seiten, aggregierte Herkunft, Gerät). Die Daten werden von Google Ireland Limited / Google LLC gemäß deren Richtlinien verarbeitet. Mit derselben Einwilligung erfassen wir außerdem aggregierte First-Party-Statistiken (Mausbewegungen, Klicks und Scrolltiefe, ohne Nutzeridentifikation) für interne Heatmaps zur Produktverbesserung und Plattform-Marketing. Wir tracken weder Admin-Bereich, Regie, Live-Player noch Replay-Seiten." s4_2_active_html: "Aktive Mess-ID auf der Website: %{id}" s4_2_inactive: Google Analytics wird nur konfiguriert, wenn der Verantwortliche die Mess-ID auf dem Server einrichtet. table2_col_name: Name @@ -186,7 +186,7 @@ de: s4_2_row3_duration: 24 Stunden s4_2_row3_provider: Google s4_2_row4_name_html: "Heatmap / Scroll (First-Party)" - s4_2_row4_purpose: Aggregierte Klick- und Scrolltiefe-Zählungen nach Seite und Gerätetyp (keine Nutzerkennungen) + s4_2_row4_purpose: Aggregierte Mausbewegungs-, Klick- und Scrolltiefe-Zählungen nach Seite und Gerätetyp (keine Nutzerkennungen) s4_2_row4_duration: Aggregate bis zu 30 Tage s4_2_row4_provider: Match Live TV (First-Party) s4_2_p2_html: "Sie können die Einwilligung über das Banner oder die Browsereinstellungen widerrufen. Google-Informationen: %{google_privacy_link}, %{google_optout_link}." diff --git a/backend/config/locales/legal.en.yml b/backend/config/locales/legal.en.yml index ca515d6..805d34e 100644 --- a/backend/config/locales/legal.en.yml +++ b/backend/config/locales/legal.en.yml @@ -88,7 +88,7 @@ en: s10_title: 10. Security s10_body: "We adopt appropriate technical and organisational measures (authenticated access, encrypted passwords, HTTPS communications, environment segregation, restricted staff access). No system is invulnerable: if you suspect unauthorised access, change your password and contact us." s11_title: 11. Cookies and similar technologies - s11_p1_html: "The site uses necessary technical cookies (login session, security, storage of cookie preferences) and, subject to consent via the banner, Google Analytics and first-party aggregated statistics (click/scroll heatmaps) to improve the product." + s11_p1_html: "The site uses necessary technical cookies (login session, security, storage of cookie preferences) and, subject to consent via the banner, Google Analytics and first-party aggregated statistics (mouse-move/click/scroll heatmaps) to improve the product." s11_p2_html: "You can manage your preferences at any time via the “Manage cookies” link in the footer or consult the %{cookie_policy_link}." s11_cookie_policy_link_text: Cookie policy s12_title: 12. Changes @@ -169,7 +169,7 @@ en: s4_1_row2_duration: 12 months s4_1_row2_provider: Match Live TV (first-party) s4_2_title: 4.2 Analytics (only with your consent) - s4_2_p1_html: "If you accept “All cookies” or enable statistics in the banner, we load Google Analytics 4 to understand how the site is used (pages visited, aggregated origin, device). Data is processed by Google Ireland Limited / Google LLC in accordance with their policies. With the same consent we also collect first-party aggregated statistics (clicks and scroll depth, without identifying the user) for internal heatmaps used to improve the product and platform marketing. We do not track the admin area, control room, live player or replay pages." + s4_2_p1_html: "If you accept “All cookies” or enable statistics in the banner, we load Google Analytics 4 to understand how the site is used (pages visited, aggregated origin, device). Data is processed by Google Ireland Limited / Google LLC in accordance with their policies. With the same consent we also collect first-party aggregated statistics (mouse movements, clicks and scroll depth, without identifying the user) for internal heatmaps used to improve the product and platform marketing. We do not track the admin area, control room, live player or replay pages." s4_2_active_html: "Measurement ID active on the site: %{id}" s4_2_inactive: Google Analytics is only configured when the controller sets the measurement ID on the server. table2_col_name: Name @@ -186,7 +186,7 @@ en: s4_2_row3_duration: 24 hours s4_2_row3_provider: Google s4_2_row4_name_html: "Heatmap / scroll (first-party)" - s4_2_row4_purpose: Aggregated click and scroll-depth counts by page and device type (no user identifiers) + s4_2_row4_purpose: Aggregated mouse-move, click and scroll-depth counts by page and device type (no user identifiers) s4_2_row4_duration: Aggregates kept up to 30 days s4_2_row4_provider: Match Live TV (first-party) s4_2_p2_html: "You can withdraw consent from the banner or from your browser settings. Google information: %{google_privacy_link}, %{google_optout_link}." diff --git a/backend/config/locales/legal.es.yml b/backend/config/locales/legal.es.yml index 88eb546..b04c5ff 100644 --- a/backend/config/locales/legal.es.yml +++ b/backend/config/locales/legal.es.yml @@ -88,7 +88,7 @@ es: s10_title: 10. Seguridad s10_body: "Adoptamos medidas técnicas y organizativas adecuadas (acceso autenticado, contraseñas cifradas, comunicaciones HTTPS, segregación de entornos, acceso restringido del personal). Ningún sistema es invulnerable: si sospechas un acceso no autorizado, cambia la contraseña y contáctanos." s11_title: 11. Cookies y tecnologías similares - s11_p1_html: "El sitio utiliza cookies técnicas necesarias (sesión de acceso, seguridad, almacenamiento de preferencias de cookies) y, previo consentimiento a través del banner, Google Analytics y estadísticas agregadas de primera parte (heatmaps de clics/scroll) para mejorar el producto." + s11_p1_html: "El sitio utiliza cookies técnicas necesarias (sesión de acceso, seguridad, almacenamiento de preferencias de cookies) y, previo consentimiento a través del banner, Google Analytics y estadísticas agregadas de primera parte (heatmaps de movimientos del ratón/clics/scroll) para mejorar el producto." s11_p2_html: "Puedes gestionar tus preferencias en cualquier momento desde el enlace «Gestionar cookies» en el pie de página o consultar la %{cookie_policy_link}." s11_cookie_policy_link_text: política de cookies s12_title: 12. Modificaciones @@ -169,7 +169,7 @@ es: s4_1_row2_duration: 12 meses s4_1_row2_provider: Match Live TV (propia) s4_2_title: 4.2 Analíticas (solo con tu consentimiento) - s4_2_p1_html: "Si aceptas «Todas las cookies» o activas las estadísticas en el banner, cargamos Google Analytics 4 para entender cómo se usa el sitio (páginas visitadas, procedencia agregada, dispositivo). Los datos son tratados por Google Ireland Limited / Google LLC conforme a sus políticas. Con el mismo consentimiento también recopilamos estadísticas agregadas de primera parte (clics y profundidad de scroll, sin identificar al usuario) para heatmaps internas de mejora del producto y marketing de la plataforma. No rastreamos el área admin, la regia, el player en directo ni las páginas de replay." + s4_2_p1_html: "Si aceptas «Todas las cookies» o activas las estadísticas en el banner, cargamos Google Analytics 4 para entender cómo se usa el sitio (páginas visitadas, procedencia agregada, dispositivo). Los datos son tratados por Google Ireland Limited / Google LLC conforme a sus políticas. Con el mismo consentimiento también recopilamos estadísticas agregadas de primera parte (movimientos del ratón, clics y profundidad de scroll, sin identificar al usuario) para heatmaps internas de mejora del producto y marketing de la plataforma. No rastreamos el área admin, la regia, el player en directo ni las páginas de replay." s4_2_active_html: "ID de medición activo en el sitio: %{id}" s4_2_inactive: Google Analytics solo se configura cuando el responsable establece el ID de medición en el servidor. table2_col_name: Nombre @@ -186,7 +186,7 @@ es: s4_2_row3_duration: 24 horas s4_2_row3_provider: Google s4_2_row4_name_html: "Heatmap / scroll (propia)" - s4_2_row4_purpose: Conteos agregados de clics y profundidad de scroll por página y tipo de dispositivo (sin identificadores de usuario) + s4_2_row4_purpose: Conteos agregados de movimientos del ratón, clics y profundidad de scroll por página y tipo de dispositivo (sin identificadores de usuario) s4_2_row4_duration: Agregados hasta 30 días s4_2_row4_provider: Match Live TV (propia) s4_2_p2_html: "Puedes revocar el consentimiento desde el banner o desde la configuración del navegador. Información de Google: %{google_privacy_link}, %{google_optout_link}." diff --git a/backend/config/locales/legal.fr.yml b/backend/config/locales/legal.fr.yml index a4f0b10..d2301f1 100644 --- a/backend/config/locales/legal.fr.yml +++ b/backend/config/locales/legal.fr.yml @@ -88,7 +88,7 @@ fr: s10_title: 10. Sécurité s10_body: "Nous adoptons des mesures techniques et organisationnelles appropriées (accès authentifié, mots de passe chiffrés, communications HTTPS, séparation des environnements, accès du staff limité). Aucun système n'est invulnérable : si vous suspectez un accès non autorisé, changez votre mot de passe et contactez-nous." s11_title: 11. Cookies et technologies similaires - s11_p1_html: "Le site utilise des cookies techniques nécessaires (session de connexion, sécurité, mémorisation des préférences cookies) et, sous réserve de consentement via la bannière, Google Analytics et des statistiques agrégées first-party (heatmaps clics/scroll) pour améliorer le produit." + s11_p1_html: "Le site utilise des cookies techniques nécessaires (session de connexion, sécurité, mémorisation des préférences cookies) et, sous réserve de consentement via la bannière, Google Analytics et des statistiques agrégées first-party (heatmaps mouvements souris/clics/scroll) pour améliorer le produit." s11_p2_html: "Vous pouvez gérer vos préférences à tout moment via le lien « Gérer les cookies » dans le pied de page ou consulter la %{cookie_policy_link}." s11_cookie_policy_link_text: politique de cookies s12_title: 12. Modifications @@ -169,7 +169,7 @@ fr: s4_1_row2_duration: 12 mois s4_1_row2_provider: Match Live TV (première partie) s4_2_title: 4.2 Analytics (uniquement avec votre consentement) - s4_2_p1_html: "Si vous acceptez « Tous les cookies » ou activez les statistiques dans la bannière, nous chargeons Google Analytics 4 pour comprendre comment le site est utilisé (pages visitées, provenance agrégée, appareil). Les données sont traitées par Google Ireland Limited / Google LLC conformément à leurs politiques. Avec le même consentement, nous collectons aussi des statistiques agrégées first-party (clics et profondeur de scroll, sans identifier l'utilisateur) pour des heatmaps internes d'amélioration produit et marketing plateforme. Nous ne suivons pas l'admin, la régie, le player live ni les pages replay." + s4_2_p1_html: "Si vous acceptez « Tous les cookies » ou activez les statistiques dans la bannière, nous chargeons Google Analytics 4 pour comprendre comment le site est utilisé (pages visitées, provenance agrégée, appareil). Les données sont traitées par Google Ireland Limited / Google LLC conformément à leurs politiques. Avec le même consentement, nous collectons aussi des statistiques agrégées first-party (mouvements de souris, clics et profondeur de scroll, sans identifier l'utilisateur) pour des heatmaps internes d'amélioration produit et marketing plateforme. Nous ne suivons pas l'admin, la régie, le player live ni les pages replay." s4_2_active_html: "ID de mesure actif sur le site : %{id}" s4_2_inactive: Google Analytics n'est configuré que lorsque le responsable définit l'ID de mesure sur le serveur. table2_col_name: Nom @@ -186,7 +186,7 @@ fr: s4_2_row3_duration: 24 heures s4_2_row3_provider: Google s4_2_row4_name_html: "Heatmap / scroll (première partie)" - s4_2_row4_purpose: Compteurs agrégés de clics et de profondeur de scroll par page et type d'appareil (sans identifiant utilisateur) + s4_2_row4_purpose: Compteurs agrégés de mouvements souris, clics et profondeur de scroll par page et type d'appareil (sans identifiant utilisateur) s4_2_row4_duration: Agrégats conservés jusqu'à 30 jours s4_2_row4_provider: Match Live TV (première partie) s4_2_p2_html: "Vous pouvez retirer votre consentement depuis la bannière ou depuis les paramètres du navigateur. Informations Google : %{google_privacy_link}, %{google_optout_link}." diff --git a/backend/config/locales/legal.it.yml b/backend/config/locales/legal.it.yml index 401e143..0b26161 100644 --- a/backend/config/locales/legal.it.yml +++ b/backend/config/locales/legal.it.yml @@ -88,7 +88,7 @@ it: s10_title: 10. Sicurezza s10_body: "Adottiamo misure tecniche e organizzative adeguate (accesso autenticato, password crittografate, comunicazioni HTTPS, segregazione ambienti, limitazione accessi staff). Nessun sistema è invulnerabile: se sospetti un accesso non autorizzato, cambia password e contattaci." s11_title: 11. Cookie e tecnologie simili - s11_p1_html: "Il sito utilizza cookie tecnici necessari (sessione di login, sicurezza, memorizzazione delle preferenze cookie) e, previo consenso tramite il banner, Google Analytics e statistiche aggregate di prima parte (heatmap click/scroll) per migliorare il prodotto." + s11_p1_html: "Il sito utilizza cookie tecnici necessari (sessione di login, sicurezza, memorizzazione delle preferenze cookie) e, previo consenso tramite il banner, Google Analytics e statistiche aggregate di prima parte (heatmap movimenti mouse/click/scroll) per migliorare il prodotto." s11_p2_html: "Puoi gestire le preferenze in qualsiasi momento dal link «Gestisci cookie» nel footer o consultare la %{cookie_policy_link}." s11_cookie_policy_link_text: Cookie policy s12_title: 12. Modifiche @@ -169,7 +169,7 @@ it: s4_1_row2_duration: 12 mesi s4_1_row2_provider: Match Live TV (prima parte) s4_2_title: 4.2 Analytics (solo con il tuo consenso) - s4_2_p1_html: "Se accetti «Tutti i cookie» o abiliti le statistiche nel banner, carichiamo Google Analytics 4 per capire come viene usato il sito (pagine visitate, provenienza aggregata, dispositivo). I dati sono trattati da Google Ireland Limited / Google LLC secondo le loro policy. In parallelo, con lo stesso consenso, raccogliamo statistiche aggregate di prima parte (click e profondità di scroll, senza identificare l’utente) per heatmaps interne usate dal titolare a fini di miglioramento del prodotto e marketing della piattaforma. Non tracciamo area admin, regia, player live né replay." + s4_2_p1_html: "Se accetti «Tutti i cookie» o abiliti le statistiche nel banner, carichiamo Google Analytics 4 per capire come viene usato il sito (pagine visitate, provenienza aggregata, dispositivo). I dati sono trattati da Google Ireland Limited / Google LLC secondo le loro policy. In parallelo, con lo stesso consenso, raccogliamo statistiche aggregate di prima parte (movimenti del mouse, click e profondità di scroll, senza identificare l’utente) per heatmaps interne usate dal titolare a fini di miglioramento del prodotto e marketing della piattaforma. Non tracciamo area admin, regia, player live né replay." s4_2_active_html: "ID misurazione attivo sul sito: %{id}" s4_2_inactive: Google Analytics è configurato solo quando il titolare imposta l’ID misurazione sul server. table2_col_name: Nome @@ -186,7 +186,7 @@ it: s4_2_row3_duration: 24 ore s4_2_row3_provider: Google s4_2_row4_name_html: "Heatmap / scroll (prima parte)" - s4_2_row4_purpose: Conteggi aggregati di click e profondità di scroll per pagina e tipo di dispositivo (nessun identificativo utente) + s4_2_row4_purpose: Conteggi aggregati di movimenti mouse, click e profondità di scroll per pagina e tipo di dispositivo (nessun identificativo utente) s4_2_row4_duration: Aggregati fino a 30 giorni s4_2_row4_provider: Match Live TV (prima parte) s4_2_p2_html: "Puoi revocare il consenso dal banner o dalle impostazioni del browser. Informazioni Google: %{google_privacy_link}, %{google_optout_link}." diff --git a/backend/db/migrate/20260820210000_add_move_heatmap_to_analytics.rb b/backend/db/migrate/20260820210000_add_move_heatmap_to_analytics.rb new file mode 100644 index 0000000..bc14b8e --- /dev/null +++ b/backend/db/migrate/20260820210000_add_move_heatmap_to_analytics.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddMoveHeatmapToAnalytics < ActiveRecord::Migration[7.2] + def change + add_column :analytics_events, :weight, :integer, null: false, default: 1 + add_column :analytics_page_cells, :move_count, :integer, null: false, default: 0 + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index d25ac60..088e9c2 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_08_20_200000) do +ActiveRecord::Schema[7.2].define(version: 2026_08_20_210000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -61,6 +61,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_200000) do t.datetime "occurred_at", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "weight", default: 1, null: false t.index ["event_type", "occurred_at"], name: "index_analytics_events_on_event_type_and_occurred_at" t.index ["occurred_at"], name: "index_analytics_events_on_occurred_at" end @@ -74,6 +75,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_200000) do t.integer "click_count", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "move_count", default: 0, null: false t.index ["day", "page_path", "device", "cell_x", "cell_y"], name: "index_analytics_page_cells_unique", unique: true end diff --git a/backend/public/admin-analytics-heatmap.js b/backend/public/admin-analytics-heatmap.js new file mode 100644 index 0000000..37d72b1 --- /dev/null +++ b/backend/public/admin-analytics-heatmap.js @@ -0,0 +1,67 @@ +/*! Admin heatmap overlay renderer (Hotjar-like radial blobs over page preview). */ +(function () { + function colorFor(t) { + // green -> yellow -> red + if (t < 0.33) { + var k = t / 0.33; + return [67 + (255 - 67) * k, 160 + (235 - 160) * k, 71 * (1 - k)]; + } + if (t < 0.66) { + var k2 = (t - 0.33) / 0.33; + return [255, 235 - (235 - 152) * k2, 0]; + } + var k3 = (t - 0.66) / 0.34; + return [255, 152 * (1 - k3), 0]; + } + + function paint() { + var canvas = document.getElementById("admin-heatmap"); + var stage = document.getElementById("admin-heatmap-stage"); + if (!canvas || !stage) 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 width = Math.max(stage.clientWidth, 320); + var height = Math.max(stage.clientHeight, 480); + canvas.width = width; + canvas.height = height; + canvas.style.width = width + "px"; + canvas.style.height = height + "px"; + + var ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, width, height); + + var cellW = width / grid; + var cellH = height / grid; + var radius = Math.max(cellW, cellH) * 1.8; + + cells.forEach(function (cell) { + var intensity = Math.min(1, cell.c / max); + if (intensity <= 0) return; + var cx = (cell.x + 0.5) * cellW; + var cy = (cell.y + 0.5) * cellH; + var rgb = colorFor(intensity); + var grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius); + grd.addColorStop(0, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + (0.2 + intensity * 0.55) + ")"); + grd.addColorStop(1, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ",0)"); + ctx.fillStyle = grd; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fill(); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", paint); + } else { + paint(); + } + window.addEventListener("resize", paint); +})(); diff --git a/backend/public/admin.css b/backend/public/admin.css index cfb8672..55ed938 100644 --- a/backend/public/admin.css +++ b/backend/public/admin.css @@ -960,20 +960,54 @@ body.admin-body { transform: translateX(-1px); } -.admin-heatmap-wrap { - overflow: auto; - max-height: 70vh; +.admin-heatmap-stage { + position: relative; + overflow: hidden; + min-height: 70vh; + max-height: 85vh; border: 1px solid var(--card-border); border-radius: 10px; - background: #0d0d12; + background: #111; } -.admin-heatmap { - display: block; +.admin-heatmap-frame { + position: absolute; + inset: 0; width: 100%; - height: auto; - max-width: 800px; - margin: 0 auto; + height: 100%; + border: 0; + background: #fff; + pointer-events: none; +} + +.admin-heatmap-fallback { + position: absolute; + inset: 0; + background: + radial-gradient(circle at 20% 20%, #1e293b 0%, transparent 45%), + radial-gradient(circle at 80% 10%, #0f172a 0%, transparent 40%), + #0d0d12; +} + +.admin-heatmap-fallback-note { + position: absolute; + z-index: 2; + left: 1rem; + top: 1rem; + margin: 0; + max-width: 28rem; + padding: 0.5rem 0.75rem; + border-radius: 8px; + background: rgba(0, 0, 0, 0.55); +} + +.admin-heatmap-overlay { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 1; + pointer-events: none; } diff --git a/backend/public/site-analytics.js b/backend/public/site-analytics.js index be5357f..13a1b36 100644 --- a/backend/public/site-analytics.js +++ b/backend/public/site-analytics.js @@ -1,17 +1,21 @@ -/*! Match Live TV first-party site analytics (click + scroll). Requires analytics cookie consent. */ +/*! Match Live TV first-party site analytics (click + mouse move + scroll). Requires analytics cookie consent. */ (function () { "use strict"; var ENDPOINT = "/analytics/events"; var STORAGE_KEY = "mltv_cookie_consent"; var COOKIE_NAME = "mltv_cookie_consent"; - var FLUSH_MS = 8000; - var MAX_QUEUE = 40; + var FLUSH_MS = 6000; + var MAX_QUEUE = 60; + var MOVE_THROTTLE_MS = 180; + var MOVE_GRID = 40; var queue = []; + var moveBuckets = {}; var maxScroll = 0; var flushTimer = null; var started = false; + var lastMoveAt = 0; function excludedPath(pathname) { var p = pathname || "/"; @@ -44,6 +48,15 @@ return "desktop"; } + function pageSize() { + var doc = document.documentElement; + var body = document.body; + return { + width: Math.max(body.scrollWidth || 0, doc.scrollWidth || 0, window.innerWidth || 1), + height: Math.max(body.scrollHeight || 0, doc.scrollHeight || 0, window.innerHeight || 1) + }; + } + function scrollPct() { var doc = document.documentElement; var body = document.body; @@ -56,8 +69,7 @@ doc.clientHeight || 0 ); var view = window.innerHeight || doc.clientHeight || 0; - var maxScrollable = Math.max(height - view, 1); - var pct = ((scrollTop + view) / height) * 100; + var pct = ((scrollTop + view) / Math.max(height, 1)) * 100; if (scrollTop <= 0 && view >= height) return 100; return Math.max(0, Math.min(100, Math.round(pct))); } @@ -65,10 +77,31 @@ function pushEvent(evt) { if (!started) return; queue.push(evt); - if (queue.length >= MAX_QUEUE) flush(true); + if (queue.length >= MAX_QUEUE) flush(false); + } + + function flushMovesIntoQueue() { + var keys = Object.keys(moveBuckets); + if (!keys.length) return; + var device = deviceBucket(); + var path = location.pathname; + var ts = Date.now(); + keys.forEach(function (key) { + var parts = key.split(","); + var cx = parseInt(parts[0], 10); + var cy = parseInt(parts[1], 10); + var n = moveBuckets[key]; + if (!n) return; + // cell center in % + var x = ((cx + 0.5) / MOVE_GRID) * 100; + var y = ((cy + 0.5) / MOVE_GRID) * 100; + pushEvent({ type: "move", path: path, device: device, x: x, y: y, n: n, ts: ts }); + }); + moveBuckets = {}; } function flush(useBeacon) { + flushMovesIntoQueue(); if (!queue.length || !hasAnalyticsConsent()) { queue = []; return; @@ -107,10 +140,7 @@ if (!(target instanceof Element)) return; if (target.closest("input, textarea, select, [contenteditable='true']")) return; - var doc = document.documentElement; - var body = document.body; - var width = Math.max(body.scrollWidth || 0, doc.scrollWidth || 0, window.innerWidth || 1); - var height = Math.max(body.scrollHeight || 0, doc.scrollHeight || 0, window.innerHeight || 1); + var size = pageSize(); var x = event.pageX; var y = event.pageY; if (typeof x !== "number" || typeof y !== "number") return; @@ -119,12 +149,28 @@ type: "click", path: location.pathname, device: deviceBucket(), - x: Math.max(0, Math.min(100, (x / width) * 100)), - y: Math.max(0, Math.min(100, (y / height) * 100)), + x: Math.max(0, Math.min(100, (x / size.width) * 100)), + y: Math.max(0, Math.min(100, (y / size.height) * 100)), ts: Date.now() }); } + function onMouseMove(event) { + var now = Date.now(); + if (now - lastMoveAt < MOVE_THROTTLE_MS) return; + lastMoveAt = now; + var size = pageSize(); + var x = event.pageX; + var y = event.pageY; + if (typeof x !== "number" || typeof y !== "number") return; + var xPct = Math.max(0, Math.min(100, (x / size.width) * 100)); + var yPct = Math.max(0, Math.min(100, (y / size.height) * 100)); + var cx = Math.min(MOVE_GRID - 1, Math.max(0, Math.floor((xPct / 100) * MOVE_GRID))); + var cy = Math.min(MOVE_GRID - 1, Math.max(0, Math.floor((yPct / 100) * MOVE_GRID))); + var key = cx + "," + cy; + moveBuckets[key] = (moveBuckets[key] || 0) + 1; + } + function onScroll() { var pct = scrollPct(); if (pct > maxScroll) maxScroll = pct; @@ -157,6 +203,7 @@ maxScroll = scrollPct(); trackPageview(); document.addEventListener("click", onClick, true); + document.addEventListener("mousemove", onMouseMove, { passive: true }); window.addEventListener("scroll", onScroll, { passive: true }); document.addEventListener("visibilitychange", function () { if (document.visibilityState === "hidden") { diff --git a/backend/spec/requests/admin/analytics_spec.rb b/backend/spec/requests/admin/analytics_spec.rb index fb22d6b..acc34b7 100644 --- a/backend/spec/requests/admin/analytics_spec.rb +++ b/backend/spec/requests/admin/analytics_spec.rb @@ -22,7 +22,8 @@ RSpec.describe "Admin analytics", type: :request do device: "desktop", cell_x: 10, cell_y: 15, - click_count: 4 + click_count: 4, + move_count: 40 ) end @@ -31,12 +32,14 @@ RSpec.describe "Admin analytics", type: :request do expect(response).to have_http_status(:ok) expect(response.body).to include("/prezzi") expect(response.body).to include("12") + expect(response.body).to include("40") end it "mostra la heatmap di una pagina" do get admin_analytics_page_path, params: { page_path: "/prezzi" } expect(response).to have_http_status(:ok) expect(response.body).to include("admin-heatmap") + expect(response.body).to include("admin-heatmap-frame") expect(response.body).to include("/prezzi") end end diff --git a/backend/spec/requests/analytics/events_spec.rb b/backend/spec/requests/analytics/events_spec.rb index df679d6..67c228d 100644 --- a/backend/spec/requests/analytics/events_spec.rb +++ b/backend/spec/requests/analytics/events_spec.rb @@ -21,6 +21,15 @@ RSpec.describe "Analytics ingest", type: :request do y: 50, ts: Time.current.to_i * 1000 }, + { + type: "move", + path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35", + device: "desktop", + x: 40, + y: 30, + n: 12, + ts: Time.current.to_i * 1000 + }, { type: "scroll", path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35", @@ -34,12 +43,13 @@ RSpec.describe "Analytics ingest", type: :request do expect(response).to have_http_status(:accepted) body = JSON.parse(response.body) - expect(body["accepted"]).to eq(3) + expect(body["accepted"]).to eq(4) expect(AnalyticsEvent.count).to eq(0) expect(AnalyticsPageStat.find_by(page_path: "/clubs/:id").pageview_count).to eq(1) expect(AnalyticsPageStat.find_by(page_path: "/clubs/:id").max_scroll_pct).to eq(80) expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:click_count)).to eq(1) + expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:move_count)).to eq(12) end it "rifiuta path esclusi" do