Admin analytics: heatmap per device, anteprima staff e analisi costi.

Separa heatmap mobile/desktop, opt-out analytics per operatori, dashboard costi con KPI e trend mensili.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 19:25:38 +02:00
co-authored by Cursor
parent e0e07e5316
commit 29c8afb94d
43 changed files with 1572 additions and 58 deletions
@@ -8,6 +8,7 @@ module Admin
to: parse_date(params[:to]) || Time.zone.today, to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence device: params[:device].presence
} }
@analytics_preview_active = analytics_preview_active?
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to]) scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
@@ -41,15 +42,23 @@ module Admin
@page_path = params[:page_path].to_s @page_path = params[:page_path].to_s
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank? redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
from = parse_date(params[:from]) || 7.days.ago.to_date
to = parse_date(params[:to]) || Time.zone.today
@device_tab_stats = device_tab_stats(@page_path, from, to)
device = resolve_heatmap_device(@device_tab_stats, params[:device].presence)
@filters = { @filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date, from: from,
to: parse_date(params[:to]) || Time.zone.today, to: to,
device: params[:device].presence, device: device,
layer: params[:layer].to_s layer: params[:layer].to_s
} }
cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) cells = AnalyticsPageCell.where(
cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) page_path: @page_path,
day: @filters[:from]..@filters[:to],
device: @filters[:device]
)
@click_total = cells.sum(:click_count) @click_total = cells.sum(:click_count)
@move_total = cells.sum(:move_count) @move_total = cells.sum(:move_count)
@@ -67,8 +76,11 @@ module Admin
@max_weight = @cells.values.max.to_i @max_weight = @cells.values.max.to_i
@total_points = @cells.values.sum @total_points = @cells.values.sum
stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) stats = AnalyticsPageStat.where(
stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) page_path: @page_path,
day: @filters[:from]..@filters[:to],
device: @filters[:device]
)
@pageviews = stats.sum(:pageview_count) @pageviews = stats.sum(:pageview_count)
@scroll_samples = stats.sum(:scroll_samples) @scroll_samples = stats.sum(:scroll_samples)
@scroll_sum = stats.sum(:scroll_sum_pct) @scroll_sum = stats.sum(:scroll_sum_pct)
@@ -78,8 +90,30 @@ module Admin
@snapshot = find_snapshot(@page_path, @filters[:device]) @snapshot = find_snapshot(@page_path, @filters[:device])
end end
def preview_enable
Analytics::Suppress.enable!(cookies)
redirect_to preview_return_to(params[:return_to]), notice: t("admin.analytics.preview.enabled")
end
def preview_disable
Analytics::Suppress.disable!(cookies)
redirect_to admin_analytics_path, notice: t("admin.analytics.preview.disabled")
end
private private
def analytics_preview_active?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
def preview_return_to(value)
path = value.to_s.strip
return root_path if path.blank?
return path if path.start_with?("/") && !path.start_with?("//")
admin_analytics_path
end
def parse_date(value) def parse_date(value)
return nil if value.blank? return nil if value.blank?
@@ -88,14 +122,43 @@ module Admin
nil nil
end end
def find_snapshot(page_path, device) def device_tab_stats(page_path, from, to)
scope = AnalyticsPageSnapshot.where(page_path: page_path) cell_totals = AnalyticsPageCell.where(page_path: page_path, day: from..to)
if device.present? && AnalyticsEvent::DEVICES.include?(device) .group(:device)
snap = scope.find_by(device: device) .pluck(
return snap if snap&.image&.attached? :device,
Arel.sql("SUM(click_count)"),
Arel.sql("SUM(move_count)")
)
cell_by_device = cell_totals.to_h { |device, clicks, moves| [device, { clicks: clicks.to_i, moves: moves.to_i }] }
pageview_totals = AnalyticsPageStat.where(page_path: page_path, day: from..to)
.group(:device)
.sum(:pageview_count)
AnalyticsEvent::DEVICES.index_with do |device|
cells = cell_by_device[device] || { clicks: 0, moves: 0 }
cells.merge(pageviews: pageview_totals[device].to_i)
end
end end
scope.order(captured_at: :desc).detect { |s| s.image.attached? } def resolve_heatmap_device(tab_stats, requested)
if requested.present? && AnalyticsEvent::DEVICES.include?(requested)
return requested
end
AnalyticsEvent::DEVICES.max_by do |device|
stats = tab_stats[device]
stats[:clicks] + stats[:moves] + stats[:pageviews]
end
end
def find_snapshot(page_path, device)
return nil unless AnalyticsEvent::DEVICES.include?(device)
AnalyticsPageSnapshot.where(page_path: page_path, device: device)
.order(captured_at: :desc)
.detect { |snapshot| snapshot.image.attached? }
end end
end end
end end
@@ -0,0 +1,68 @@
# frozen_string_literal: true
module Admin
class CostEntriesController < Admin::BaseController
before_action :set_entry, only: %i[edit update destroy]
def new
@entry = PlatformCostEntry.new(
month: parse_month(params[:month]) || Time.zone.today.beginning_of_month,
label: PlatformCostEntry::DEFAULT_LABEL
)
end
def create
@entry = PlatformCostEntry.new(entry_attributes)
if @entry.save
redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_created")
else
flash.now[:alert] = @entry.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @entry.update(entry_attributes)
redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_updated")
else
flash.now[:alert] = @entry.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
end
def destroy
month = @entry.month
@entry.destroy!
redirect_to admin_costs_path(month: month_param(month)), notice: t("admin.flash.cost_entry_destroyed")
end
private
def set_entry
@entry = PlatformCostEntry.find(params[:id])
end
def entry_attributes
attrs = params.require(:platform_cost_entry).permit(:month, :label, :amount_euros, :notes)
if attrs[:amount_euros].present?
attrs[:amount_cents] = Billing::EuroAmount.to_cents(attrs.delete(:amount_euros))
end
attrs[:notes] = nil if attrs[:notes].blank?
attrs
end
def parse_month(value)
return nil if value.blank?
Date.strptime(value.to_s, "%Y-%m").beginning_of_month
rescue ArgumentError, TypeError
nil
end
def month_param(date)
date.strftime("%Y-%m")
end
end
end
@@ -0,0 +1,30 @@
# frozen_string_literal: true
module Admin
class CostsController < Admin::BaseController
def index
@month = parse_month(params[:month]) || Time.zone.today.beginning_of_month
analytics = Admin::CostAnalytics.new(month: @month)
@summary = analytics.summary
@trend = analytics.trend
@clubs = analytics.club_breakdown
@entries = PlatformCostEntry.for_month(@month).ordered
@month_options = month_options(@month)
end
private
def parse_month(value)
return nil if value.blank?
Date.strptime(value.to_s, "%Y-%m").beginning_of_month
rescue ArgumentError, TypeError
nil
end
def month_options(selected)
start = selected - 23.months
(0..23).map { |i| start + i.months }.reverse
end
end
end
@@ -2,7 +2,13 @@
module Analytics module Analytics
class EventsController < ActionController::API class EventsController < ActionController::API
include ActionController::Cookies
def create def create
if analytics_suppressed?
return render json: { accepted: 0, rejected: 0, suppressed: true }, status: :accepted
end
payload = parse_payload payload = parse_payload
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
@@ -15,6 +21,10 @@ module Analytics
private private
def analytics_suppressed?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
def parse_payload def parse_payload
body = request.request_parameters body = request.request_parameters
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array) return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
@@ -2,7 +2,13 @@
module Analytics module Analytics
class SnapshotsController < ActionController::API class SnapshotsController < ActionController::API
include ActionController::Cookies
def create def create
if analytics_suppressed?
return render json: { ok: true, skipped: true, suppressed: true }, status: :accepted
end
result = Analytics::SnapshotIngest.new( result = Analytics::SnapshotIngest.new(
path: params[:path] || params[:page_path], path: params[:path] || params[:page_path],
device: params[:device], device: params[:device],
@@ -17,5 +23,11 @@ module Analytics
render json: { ok: true, skipped: result.skipped }, status: :accepted render json: { ok: true, skipped: result.skipped }, status: :accepted
end end
private
def analytics_suppressed?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
end end
end end
+17
View File
@@ -1,4 +1,21 @@
module AdminHelper module AdminHelper
def format_euros(cents, precision: 2)
return I18n.t("admin.common.dash") if cents.nil?
format("%.*f €", precision, cents.to_f / 100.0)
end
def format_hours(hours)
return I18n.t("admin.common.dash") if hours.nil? || hours.to_f <= 0
total_minutes = (hours.to_f * 60).round
format_duration_minutes(total_minutes)
end
def admin_month_label(month)
I18n.l(month, format: "%B %Y")
end
def format_bytes(bytes) def format_bytes(bytes)
return "" if bytes.nil? return "" if bytes.nil?
+51
View File
@@ -0,0 +1,51 @@
# frozen_string_literal: true
class PlatformCostEntry < ApplicationRecord
DEFAULT_LABEL = "Piattaforma produzione"
validates :month, presence: true
validates :label, presence: true, length: { maximum: 120 }
validates :amount_cents, numericality: { only_integer: true, greater_than: 0 }
before_validation :normalize_month
scope :for_month, ->(date) { where(month: date.to_date.beginning_of_month) }
scope :ordered, -> { order(month: :desc, created_at: :desc) }
def month=(value)
if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}\z/)
super(Date.strptime(value, "%Y-%m"))
else
super(value)
end
end
def amount_euros
amount_cents.to_f / 100.0
end
private
def normalize_month
return if month.blank?
parsed =
case month
when Date
month
when Time, ActiveSupport::TimeWithZone
month.to_date
when String
if month.match?(/\A\d{4}-\d{2}\z/)
Date.strptime(month, "%Y-%m")
else
Date.parse(month)
end
else
Date.parse(month.to_s)
end
self.month = parsed.beginning_of_month
rescue ArgumentError, TypeError
errors.add(:month, :invalid)
end
end
@@ -0,0 +1,150 @@
# frozen_string_literal: true
module Admin
class CostAnalytics
TREND_MONTHS = 12
def initialize(month:)
@month = month.to_date.beginning_of_month
end
def summary
build_period(@month)
end
def trend(months: TREND_MONTHS)
start_month = @month - (months - 1).months
months_list = (0...months).map { |i| start_month + i.months }
months_list.map { |m| build_period(m) }
end
def club_breakdown
range = month_range(@month)
rows = session_rows(range)
total_secs = rows.sum { |row| row.total_secs.to_i }
total_cost_cents = PlatformCostEntry.for_month(@month).sum(:amount_cents)
revenue_by_club = revenue_by_club(range)
storage_by_club = storage_by_club_index
club_ids = rows.map(&:club_id)
clubs = Club.where(id: club_ids).includes(subscription: :plan).index_by(&:id)
rows.map do |row|
club = clubs[row.club_id]
secs = row.total_secs.to_i
hours = secs / 3600.0
sessions_count = row.sessions_count.to_i
share = total_secs.positive? ? secs.to_f / total_secs : 0.0
allocated_cents = (total_cost_cents * share).round
revenue_cents = revenue_by_club[row.club_id].to_i
storage_bytes = storage_by_club[row.club_id].to_i
{
club_id: row.club_id,
club_name: row.club_name,
plan_slug: club&.subscription&.plan&.slug,
sessions: sessions_count,
hours: hours.round(2),
hours_share_pct: (share * 100).round(1),
allocated_cost_cents: allocated_cents,
revenue_cents: revenue_cents,
margin_cents: revenue_cents - allocated_cents,
cost_per_hour_cents: hours.positive? ? (allocated_cents / hours).round : nil,
cost_per_session_cents: sessions_count.positive? ? (allocated_cents / sessions_count) : nil,
storage_bytes: storage_bytes
}
end.sort_by { |row| [-row[:hours], row[:club_name]] }
end
private
def build_period(month)
range = month_range(month)
cost_cents = PlatformCostEntry.for_month(month).sum(:amount_cents)
sessions_scope = ended_sessions.where(ended_at: range)
total_secs = sessions_scope.sum(:total_duration_secs).to_i
hours = total_secs / 3600.0
sessions = sessions_scope.count
clubs_active = distinct_active_clubs(range)
revenue_cents = Billing::Payment.where(status: "paid", paid_at: range).sum(:amount_cents)
storage_bytes = Recording.ready.sum(:byte_size).to_i
storage_gb = storage_bytes.positive? ? storage_bytes / (1024.0**3) : 0.0
kpis = compute_kpis(
cost_cents: cost_cents,
hours: hours,
sessions: sessions,
clubs_active: clubs_active,
revenue_cents: revenue_cents,
storage_gb: storage_gb
)
{
month: month,
cost_cents: cost_cents,
sessions: sessions,
hours: hours.round(2),
clubs_active: clubs_active,
revenue_cents: revenue_cents,
storage_bytes: storage_bytes,
**kpis
}
end
def compute_kpis(cost_cents:, hours:, sessions:, clubs_active:, revenue_cents:, storage_gb:)
margin_cents = revenue_cents.to_i - cost_cents.to_i
margin_pct = revenue_cents.to_i.positive? ? ((margin_cents.to_f / revenue_cents.to_i) * 100).round(1) : nil
hours_f = hours.to_f
sessions_i = sessions.to_i
clubs_i = clubs_active.to_i
storage_f = storage_gb.to_f
cost_i = cost_cents.to_i
{
cost_per_hour_cents: hours_f.positive? ? (cost_i / hours_f).round : nil,
cost_per_session_cents: sessions_i.positive? ? (cost_i / sessions_i) : nil,
cost_per_club_cents: clubs_i.positive? ? (cost_i / clubs_i) : nil,
revenue_per_hour_cents: hours_f.positive? ? (revenue_cents.to_i / hours_f).round : nil,
cost_per_gb_cents: storage_f.positive? ? (cost_i / storage_f).round : nil,
margin_cents: margin_cents,
margin_pct: margin_pct
}
end
def month_range(month)
month.beginning_of_month.beginning_of_day..month.end_of_month.end_of_day
end
def ended_sessions
StreamSession.where(status: "ended")
end
def distinct_active_clubs(range)
ended_sessions
.where(ended_at: range)
.joins(match: { team: :club })
.distinct
.count("clubs.id")
end
def session_rows(range)
ended_sessions
.where(ended_at: range)
.joins(match: { team: :club })
.group("clubs.id", "clubs.name")
.select(
"clubs.id AS club_id",
"clubs.name AS club_name",
"COUNT(stream_sessions.id) AS sessions_count",
"SUM(stream_sessions.total_duration_secs) AS total_secs"
)
end
def revenue_by_club(range)
Billing::Payment.where(status: "paid", paid_at: range).group(:club_id).sum(:amount_cents)
end
def storage_by_club_index
Recording.ready.joins(:team).group("teams.club_id").sum(:byte_size)
end
end
end
@@ -0,0 +1,42 @@
# frozen_string_literal: true
module Analytics
module Suppress
COOKIE_NAME = "mltv_analytics_suppress"
COOKIE_MAX_AGE = 7 * 24 * 60 * 60
module_function
def active?(cookie_value)
cookie_value.to_s == "1"
end
def enable!(cookie_jar)
cookie_jar[COOKIE_NAME] = cookie_options(value: "1", expires: COOKIE_MAX_AGE.seconds.from_now)
end
def disable!(cookie_jar)
cookie_jar.delete(
COOKIE_NAME,
path: "/",
same_site: :lax,
secure: cookie_secure?
)
end
def cookie_options(value:, expires:)
{
value: value,
expires: expires,
path: "/",
httponly: true,
same_site: :lax,
secure: cookie_secure?
}
end
def cookie_secure?
Rails.application.config.force_ssl || Rails.env.production?
end
end
end
@@ -5,6 +5,32 @@
<p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p> <p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p>
</div> </div>
<section class="panel admin-analytics-preview">
<h3><%= t("admin.analytics.preview.title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.preview.lead") %></p>
<% if @analytics_preview_active %>
<p class="admin-analytics-preview__status"><%= t("admin.analytics.preview.active") %></p>
<div class="admin-filter-actions">
<%= button_to t("admin.analytics.preview.disable"),
admin_analytics_preview_path,
method: :delete,
class: "admin-btn admin-btn--outline admin-btn--sm" %>
<%= link_to t("admin.analytics.preview.open_site"),
root_path,
class: "admin-btn admin-btn--primary admin-btn--sm",
target: "_blank",
rel: "noopener" %>
</div>
<% else %>
<div class="admin-filter-actions">
<%= button_to t("admin.analytics.preview.enable"),
admin_analytics_preview_path(return_to: root_path),
method: :post,
class: "admin-btn admin-btn--primary admin-btn--sm" %>
</div>
<% end %>
</section>
<div class="panel admin-sessions-filters"> <div class="panel admin-sessions-filters">
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %> <%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid"> <div class="admin-filter-grid">
@@ -58,8 +84,9 @@
<td class="muted"><%= avg %>%</td> <td class="muted"><%= avg %>%</td>
<td class="muted"><%= row[:max_scroll] %>%</td> <td class="muted"><%= row[:max_scroll] %>%</td>
<td> <td>
<%= link_to t("admin.analytics.index.heatmap"), <% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %>
admin_analytics_page_path(page_path: row[:page_path], from: @filters[:from], to: @filters[:to], device: @filters[:device]) %> <% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %>
<%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %>
</td> </td>
</tr> </tr>
<% end %> <% end %>
+42 -14
View File
@@ -3,7 +3,7 @@
<div class="admin-page-head admin-session-head"> <div class="admin-page-head admin-session-head">
<div> <div>
<p class="muted admin-page-sub"> <p class="muted admin-page-sub">
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to], device: @filters[:device]) %> <%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to]) %>
</p> </p>
<h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2> <h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2>
<p><code class="admin-mono"><%= @page_path %></code></p> <p><code class="admin-mono"><%= @page_path %></code></p>
@@ -11,8 +11,32 @@
</div> </div>
<div class="panel admin-sessions-filters"> <div class="panel admin-sessions-filters">
<p class="admin-filter-field" style="margin-bottom: 0.75rem;">
<span><%= t("admin.analytics.show.device_tabs_label") %></span>
</p>
<div class="admin-locale-tabs" role="tablist">
<% AnalyticsEvent::DEVICES.each do |device| %>
<% stats = @device_tab_stats[device] %>
<% active = @filters[:device] == device %>
<%= link_to admin_analytics_page_path(
page_path: @page_path,
from: @filters[:from],
to: @filters[:to],
device: device,
layer: @filters[:layer]
),
class: "admin-locale-tab #{'is-active' if active}",
role: "tab",
"aria-selected": active do %>
<%= t("admin.analytics.devices.#{device}") %>
<span class="admin-device-tab-count"><%= stats[:pageviews] %></span>
<% end %>
<% end %>
</div>
<%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %> <%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %>
<%= hidden_field_tag :page_path, @page_path %> <%= hidden_field_tag :page_path, @page_path %>
<%= hidden_field_tag :device, @filters[:device] %>
<div class="admin-filter-grid"> <div class="admin-filter-grid">
<label class="admin-filter-field"> <label class="admin-filter-field">
<span><%= t("admin.analytics.filters.from") %></span> <span><%= t("admin.analytics.filters.from") %></span>
@@ -22,14 +46,6 @@
<span><%= t("admin.analytics.filters.to") %></span> <span><%= t("admin.analytics.filters.to") %></span>
<%= date_field_tag :to, @filters[:to] %> <%= date_field_tag :to, @filters[:to] %>
</label> </label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.device") %></span>
<%= select_tag :device,
options_for_select(
[[t("admin.analytics.filters.any"), ""]] + AnalyticsEvent::DEVICES.map { |d| [d, d] },
@filters[:device]
) %>
</label>
<label class="admin-filter-field"> <label class="admin-filter-field">
<span><%= t("admin.analytics.filters.layer") %></span> <span><%= t("admin.analytics.filters.layer") %></span>
<%= select_tag :layer, <%= select_tag :layer,
@@ -91,7 +107,12 @@
<h3> <h3>
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %> <%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
</h3> </h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.show.heatmap_hint") %></p> <p class="muted admin-table-sub">
<%= t(
"admin.analytics.show.heatmap_hint",
device: t("admin.analytics.devices.#{@filters[:device]}")
) %>
</p>
<% if @cells.any? %> <% if @cells.any? %>
<div class="admin-heatmap-stage" id="admin-heatmap-stage"> <div class="admin-heatmap-stage" id="admin-heatmap-stage">
@@ -115,13 +136,20 @@
></canvas> ></canvas>
</div> </div>
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;"> <p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
<%= t("admin.analytics.show.snapshot_meta", <%= t(
device: @snapshot.device, "admin.analytics.show.snapshot_meta",
at: l(@snapshot.captured_at, format: :short)) %> device: t("admin.analytics.devices.#{@snapshot.device}"),
at: l(@snapshot.captured_at, format: :short)
) %>
</p> </p>
<% else %> <% else %>
<div class="admin-heatmap-fallback" aria-hidden="true"></div> <div class="admin-heatmap-fallback" aria-hidden="true"></div>
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.snapshot_missing") %></p> <p class="muted admin-heatmap-fallback-note">
<%= t(
"admin.analytics.show.snapshot_missing",
device: t("admin.analytics.devices.#{@filters[:device]}")
) %>
</p>
<canvas <canvas
id="admin-heatmap" id="admin-heatmap"
class="admin-heatmap-overlay" class="admin-heatmap-overlay"
@@ -0,0 +1,34 @@
<%= form_with model: @entry, url: url, method: method, local: true, class: "admin-filter-form" do |f| %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.costs.entries.month") %></span>
<%= text_field_tag "platform_cost_entry[month]",
@entry.month&.strftime("%Y-%m"),
type: "month",
required: true %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.costs.entries.label") %></span>
<%= f.text_field :label, required: true, maxlength: 120 %>
<span class="muted admin-table-sub"><%= t("admin.costs.entries.label_hint") %></span>
</label>
<label class="admin-filter-field">
<span><%= t("admin.costs.entries.amount") %></span>
<%= number_field_tag "platform_cost_entry[amount_euros]",
(@entry.amount_cents.to_f / 100.0 if @entry.amount_cents.present?),
step: 0.01,
min: 0.01,
required: true %>
</label>
<label class="admin-filter-field" style="grid-column: span 2;">
<span><%= t("admin.costs.entries.notes") %></span>
<%= f.text_area :notes, rows: 3 %>
</label>
</div>
<div class="admin-filter-actions">
<%= f.submit t("admin.costs.entries.save"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.costs.entries.cancel"),
admin_costs_path(month: @entry.month&.strftime("%Y-%m")),
class: "admin-btn admin-btn--outline admin-btn--sm" %>
</div>
<% end %>
@@ -0,0 +1,10 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.costs.entries.edit_title") %></h2>
<p class="muted admin-page-sub"><%= @entry.label %> · <%= admin_month_label(@entry.month) %></p>
</div>
<section class="panel">
<%= render "form", url: admin_cost_entry_path(@entry), method: :patch %>
</section>
@@ -0,0 +1,9 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.costs.entries.new_title") %></h2>
</div>
<section class="panel">
<%= render "form", url: admin_cost_entries_path, method: :post %>
</section>
@@ -0,0 +1,197 @@
<% content_for :body_class, "admin-body" %>
<% content_for :head do %>
<script>
window.adminCostTrend = <%= raw @trend.to_json %>;
window.adminCostI18n = {
cost: <%= raw t("admin.costs.charts.cost_revenue").to_json %>,
revenue: <%= raw t("admin.costs.kpi.revenue").to_json %>,
margin: <%= raw t("admin.costs.kpi.margin").to_json %>,
costPerHour: <%= raw t("admin.costs.charts.cost_per_hour").to_json %>,
hours: <%= raw t("admin.costs.charts.hours").to_json %>
};
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
<script src="/admin-costs.js?v=1" defer></script>
<% end %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.costs.index.title") %></h2>
<p class="muted admin-page-sub"><%= t("admin.costs.index.lead") %></p>
</div>
<div class="panel admin-sessions-filters">
<%= form_with url: admin_costs_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.costs.index.month") %></span>
<%= select_tag :month,
options_for_select(
@month_options.map { |m| [admin_month_label(m), m.strftime("%Y-%m")] },
@month.strftime("%Y-%m")
) %>
</label>
</div>
<div class="admin-filter-actions">
<%= submit_tag t("admin.costs.index.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.costs.index.add_entry"),
new_admin_cost_entry_path(month: @month.strftime("%Y-%m")),
class: "admin-btn admin-btn--outline admin-btn--sm" %>
</div>
<% end %>
</div>
<section class="kpi-grid">
<div class="kpi-card kpi-card--accent">
<div class="kpi-label"><%= t("admin.costs.kpi.platform_cost") %></div>
<div class="kpi-value"><%= format_euros(@summary[:cost_cents]) %></div>
<div class="kpi-sub"><%= admin_month_label(@month) %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.revenue") %></div>
<div class="kpi-value"><%= format_euros(@summary[:revenue_cents]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.margin") %>: <%= format_euros(@summary[:margin_cents]) %>
<% if @summary[:margin_pct] %>
· <%= @summary[:margin_pct] %>%
<% end %>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_hour") %></div>
<div class="kpi-value"><%= format_euros(@summary[:cost_per_hour_cents]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.revenue_per_hour") %>: <%= format_euros(@summary[:revenue_per_hour_cents]) %>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.hours") %></div>
<div class="kpi-value"><%= format_hours(@summary[:hours]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.sessions") %>: <%= @summary[:sessions] %>
· <%= t("admin.costs.kpi.clubs_active") %>: <%= @summary[:clubs_active] %>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_session") %></div>
<div class="kpi-value"><%= format_euros(@summary[:cost_per_session_cents]) %></div>
<div class="kpi-sub"><%= t("admin.costs.kpi.cost_per_club") %>: <%= format_euros(@summary[:cost_per_club_cents]) %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.storage") %></div>
<div class="kpi-value" style="font-size:1.2rem"><%= format_bytes(@summary[:storage_bytes]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.cost_per_gb") %>: <%= format_euros(@summary[:cost_per_gb_cents]) %>
· <%= t("admin.costs.kpi.storage_note") %>
</div>
</div>
</section>
<section class="panel" style="margin-bottom:1.25rem">
<h3><%= t("admin.costs.index.trends_title") %></h3>
<div class="charts-grid">
<div class="chart-card">
<h3><%= t("admin.costs.charts.cost_revenue") %></h3>
<div class="chart-wrap"><canvas id="chart-cost-revenue"></canvas></div>
</div>
<div class="chart-card">
<h3><%= t("admin.costs.charts.cost_per_hour") %></h3>
<div class="chart-wrap"><canvas id="chart-cost-hour"></canvas></div>
</div>
<div class="chart-card">
<h3><%= t("admin.costs.charts.hours") %></h3>
<div class="chart-wrap"><canvas id="chart-hours"></canvas></div>
</div>
</div>
</section>
<section class="panel" style="margin-bottom:1.25rem">
<h3><%= t("admin.costs.index.entries_title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.costs.index.entries_lead") %></p>
<% if @entries.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.costs.table.label") %></th>
<th><%= t("admin.costs.table.amount") %></th>
<th><%= t("admin.costs.table.notes") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @entries.each do |entry| %>
<tr>
<td><strong><%= entry.label %></strong></td>
<td><%= format_euros(entry.amount_cents) %></td>
<td class="muted"><%= entry.notes.presence || t("admin.common.dash") %></td>
<td class="admin-actions">
<%= link_to t("admin.announcements.actions.edit"),
edit_admin_cost_entry_path(entry),
class: "admin-btn admin-btn--sm" %>
<%= button_to t("admin.costs.entries.delete"),
admin_cost_entry_path(entry),
method: :delete,
class: "admin-btn admin-btn--sm admin-btn--danger",
form: { data: { confirm: t("admin.costs.entries.delete_confirm") } } %>
</td>
</tr>
<% end %>
</tbody>
<tfoot>
<tr>
<th><%= t("admin.costs.kpi.platform_cost") %></th>
<th><%= format_euros(@summary[:cost_cents]) %></th>
<th colspan="2"></th>
</tr>
</tfoot>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.costs.index.no_entries") %></p>
<% end %>
</section>
<section class="panel">
<h3><%= t("admin.costs.index.clubs_title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.costs.index.clubs_lead") %></p>
<% if @clubs.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.costs.table.club") %></th>
<th><%= t("admin.costs.table.plan") %></th>
<th><%= t("admin.costs.table.hours") %></th>
<th><%= t("admin.costs.table.hours_share") %></th>
<th><%= t("admin.costs.table.allocated_cost") %></th>
<th><%= t("admin.costs.table.revenue") %></th>
<th><%= t("admin.costs.table.margin") %></th>
<th><%= t("admin.costs.table.cost_per_hour") %></th>
<th><%= t("admin.costs.table.cost_per_session") %></th>
<th><%= t("admin.costs.table.storage") %></th>
</tr>
</thead>
<tbody>
<% @clubs.each do |row| %>
<tr>
<td>
<%= link_to row[:club_name], admin_club_path(row[:club_id]), class: "admin-table-strong" %>
</td>
<td class="muted"><%= row[:plan_slug] || t("admin.common.dash") %></td>
<td><%= format_hours(row[:hours]) %></td>
<td class="muted"><%= row[:hours_share_pct] %>%</td>
<td><%= format_euros(row[:allocated_cost_cents]) %></td>
<td><%= format_euros(row[:revenue_cents]) %></td>
<td><%= format_euros(row[:margin_cents]) %></td>
<td><%= format_euros(row[:cost_per_hour_cents]) %></td>
<td><%= format_euros(row[:cost_per_session_cents]) %></td>
<td class="muted"><%= format_bytes(row[:storage_bytes]) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.costs.index.no_clubs") %></p>
<% end %>
</section>
+2
View File
@@ -6,6 +6,7 @@
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<link rel="stylesheet" href="/admin.css?v=15"> <link rel="stylesheet" href="/admin.css?v=15">
<%= yield :head %>
<% if content_for?(:replay_archive_styles) %> <% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=42"> <link rel="stylesheet" href="/marketing.css?v=42">
<% end %> <% end %>
@@ -32,6 +33,7 @@
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %> <%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %> <%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
<%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %> <%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %>
<%= link_to t("admin.layout.nav.costs"), admin_costs_path, class: ("active" if controller_name.in?(%w[costs cost_entries])) %>
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %> <%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %> <%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %> <%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
+4 -3
View File
@@ -7,9 +7,10 @@
<meta name="application-name" content="Match Live TV"> <meta name="application-name" content="Match Live TV">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= render "shared/analytics_suppress" %>
<%= yield :head %> <%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=76"> <link rel="stylesheet" href="/marketing.css?v=77">
</head> </head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %> <%= render "shared/cookie_banner" %>
@@ -26,7 +27,7 @@
<script src="/password-toggle.js?v=2" defer></script> <script src="/password-toggle.js?v=2" defer></script>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
<script src="/site-analytics.js?v=4" defer></script> <script src="/site-analytics.js?v=5" defer></script>
<script src="/cookie-consent.js?v=2" defer></script> <script src="/cookie-consent.js?v=3" defer></script>
</body> </body>
</html> </html>
@@ -6,8 +6,9 @@
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title> <title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= render "shared/analytics_suppress" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=76"> <link rel="stylesheet" href="/marketing.css?v=77">
<link rel="stylesheet" href="/live.css?v=26"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</head> </head>
@@ -21,7 +22,7 @@
<%= render "shared/marketing_footer" %> <%= render "shared/marketing_footer" %>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
<script src="/site-analytics.js?v=4" defer></script> <script src="/site-analytics.js?v=5" defer></script>
<script src="/cookie-consent.js?v=2" defer></script> <script src="/cookie-consent.js?v=3" defer></script>
</body> </body>
</html> </html>
@@ -0,0 +1,7 @@
<% if Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME]) %>
<meta name="mltv-analytics-suppress" content="1">
<div class="mltv-preview-badge" role="status">
<span><%= t("ui.analytics_preview.badge") %></span>
<%= link_to t("ui.analytics_preview.manage"), admin_analytics_path, class: "mltv-preview-badge__link" %>
</div>
<% end %>
+20 -2
View File
@@ -11,6 +11,7 @@ de:
youtube: YouTube youtube: YouTube
sessions: Sitzungen sessions: Sitzungen
analytics: Analytics analytics: Analytics
costs: Kosten
stream_nodes: Stream-Knoten stream_nodes: Stream-Knoten
password: Passwort password: Passwort
logout: Abmelden logout: Abmelden
@@ -50,6 +51,9 @@ de:
announcement_created: Hinweis gespeichert announcement_created: Hinweis gespeichert
announcement_updated: Hinweis aktualisiert announcement_updated: Hinweis aktualisiert
announcement_destroyed: Hinweis gelöscht announcement_destroyed: Hinweis gelöscht
cost_entry_created: Kostenposition gespeichert.
cost_entry_updated: Kostenposition aktualisiert.
cost_entry_destroyed: Kostenposition gelöscht.
common: common:
free_plan: Free free_plan: Free
yes: "Ja" yes: "Ja"
@@ -371,6 +375,10 @@ de:
layer: Ebene layer: Ebene
layer_move: Mausbewegungen layer_move: Mausbewegungen
layer_click: Klicks layer_click: Klicks
devices:
mobile: Mobil
tablet: Tablet
desktop: Desktop
index: index:
title: Website-Analytics title: Website-Analytics
lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) 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.
@@ -391,14 +399,24 @@ de:
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%" scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
heatmap_moves: Mausbewegungs-Karte heatmap_moves: Mausbewegungs-Karte
heatmap_clicks: Klickkarte heatmap_clicks: Klickkarte
heatmap_hint: Farbiges Overlay auf dem Seiten-Screenshot. Grün = wenig, Rot = viel. heatmap_hint: Farbiges Overlay auf dem %{device}-Screenshot. Grün = wenig, Rot = viel. Daten werden nicht zwischen Geräten gemischt.
device_tabs_label: Gerät
preview_title: Seiten-Screenshot preview_title: Seiten-Screenshot
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id). preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id).
snapshot_missing: Noch kein Screenshot. Seite mit Analytics-Einwilligung (Desktop) besuchen und erneut versuchen. snapshot_missing: Noch kein Screenshot für %{device}. Seite auf diesem Gerät mit Analytics-Einwilligung besuchen und erneut versuchen.
snapshot_meta: "Screenshot %{device} · %{at}" snapshot_meta: "Screenshot %{device} · %{at}"
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum. no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
heatmap_title: Klickkarte heatmap_title: Klickkarte
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum. no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
preview:
title: Vorschau ohne Tracking
lead: Öffentliche Website durchsuchen ohne Pageviews, Klicks, Bewegungen oder Heatmap-Screenshots zu speichern. Für Produktionstests.
active: Staff-Vorschau in diesem Browser aktiv (7 Tage oder bis Deaktivierung).
enable: Staff-Vorschau aktivieren
disable: Vorschau deaktivieren
open_site: Website öffnen
enabled: Staff-Vorschau aktiviert. Dieser Browser speichert keine Analytics.
disabled: Staff-Vorschau deaktiviert.
billing: billing:
index: index:
+80 -2
View File
@@ -11,6 +11,7 @@ en:
youtube: YouTube youtube: YouTube
sessions: Sessions sessions: Sessions
analytics: Analytics analytics: Analytics
costs: Costs
stream_nodes: Stream nodes stream_nodes: Stream nodes
password: Password password: Password
logout: Log out logout: Log out
@@ -50,6 +51,9 @@ en:
announcement_created: Notice saved announcement_created: Notice saved
announcement_updated: Notice updated announcement_updated: Notice updated
announcement_destroyed: Notice deleted announcement_destroyed: Notice deleted
cost_entry_created: Cost entry saved.
cost_entry_updated: Cost entry updated.
cost_entry_destroyed: Cost entry deleted.
common: common:
free_plan: Free free_plan: Free
yes: "Yes" yes: "Yes"
@@ -371,6 +375,10 @@ en:
layer: Layer layer: Layer
layer_move: Mouse moves layer_move: Mouse moves
layer_click: Clicks layer_click: Clicks
devices:
mobile: Mobile
tablet: Tablet
desktop: Desktop
index: index:
title: Site analytics title: Site analytics
lead: Aggregated first-party move/click 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.
@@ -391,14 +399,84 @@ en:
scroll_hint: "Average %{avg}% · max %{max}%" scroll_hint: "Average %{avg}% · max %{max}%"
heatmap_moves: Mouse-move map heatmap_moves: Mouse-move map
heatmap_clicks: Click map heatmap_clicks: Click map
heatmap_hint: Colored overlay on the page screenshot. Green = low, red = high. heatmap_hint: Colored overlay on the %{device} screenshot. Green = low, red = high. Data is never mixed across devices.
device_tabs_label: Device
preview_title: Page screenshot preview_title: Page screenshot
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id). preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id).
snapshot_missing: No screenshot yet. Visit the page on the site with analytics consent (desktop), then retry in a few seconds. snapshot_missing: No screenshot for %{device} yet. Visit the page on that device with analytics consent, then retry in a few seconds.
snapshot_meta: "Screenshot %{device} · captured %{at}" snapshot_meta: "Screenshot %{device} · captured %{at}"
no_points: No aggregated points for this layer in the period. no_points: No aggregated points for this layer in the period.
heatmap_title: Click map heatmap_title: Click map
no_clicks: No aggregated clicks for this page in the period. no_clicks: No aggregated clicks for this page in the period.
preview:
title: Preview without tracking
lead: Browse the public site without recording pageviews, clicks, moves or heatmap screenshots. Useful for production smoke tests.
active: Staff preview is active in this browser (7 days or until disabled).
enable: Enable staff preview
disable: Disable preview
open_site: Open site
enabled: Staff preview enabled. This browser will not record analytics.
disabled: Staff preview disabled.
costs:
index:
title: Cost analysis
lead: Enter monthly platform costs and compare cost, usage and revenue. KPIs recalculate automatically when you edit entries.
month: Month
apply: Apply
entries_title: Cost entries for the month
entries_lead: Add multiple line items (e.g. servers, storage, bandwidth). Month total is the sum of entries.
add_entry: Add entry
no_entries: No cost entries for this month. Add platform cost to see KPIs.
clubs_title: Allocated cost per club
clubs_lead: Infrastructure cost is split proportionally by streamed hours in the month.
no_clubs: No ended streams in this month.
trends_title: Trends (last 12 months)
kpi:
platform_cost: Platform cost
revenue: Revenue collected
margin: Margin
margin_pct: Margin %
hours: Streamed hours
sessions: Streams
clubs_active: Active clubs
storage: Replay storage
cost_per_hour: Cost / stream hour
cost_per_session: Cost / stream
cost_per_club: Cost / active club
revenue_per_hour: Revenue / stream hour
cost_per_gb: Cost / GB storage
storage_note: Current cumulative storage (not monthly).
charts:
cost_revenue: Cost vs revenue
cost_per_hour: Cost per streamed hour
hours: Streamed hours
table:
label: Item
amount: Amount
notes: Notes
club: Club
plan: Plan
hours: Hours
hours_share: Hour share
allocated_cost: Allocated cost
revenue: Revenue
margin: Margin
cost_per_hour: €/hour
cost_per_session: €/stream
storage: Storage
entries:
new_title: New cost entry
edit_title: Edit cost entry
month: Month
label: Description
label_hint: E.g. Hetzner, stream nodes, S3, egress.
amount: Amount (€)
notes: Notes
save: Save
cancel: Cancel
delete: Delete
delete_confirm: Delete this cost entry?
billing: billing:
index: index:
+20 -2
View File
@@ -11,6 +11,7 @@ es:
youtube: YouTube youtube: YouTube
sessions: Sesiones sessions: Sesiones
analytics: Analytics analytics: Analytics
costs: Costes
stream_nodes: Nodos stream stream_nodes: Nodos stream
password: Contraseña password: Contraseña
logout: Salir logout: Salir
@@ -50,6 +51,9 @@ es:
announcement_created: Aviso guardado announcement_created: Aviso guardado
announcement_updated: Aviso actualizado announcement_updated: Aviso actualizado
announcement_destroyed: Aviso eliminado announcement_destroyed: Aviso eliminado
cost_entry_created: Partida de coste guardada.
cost_entry_updated: Partida de coste actualizada.
cost_entry_destroyed: Partida de coste eliminada.
common: common:
free_plan: Free free_plan: Free
yes: "Sí" yes: "Sí"
@@ -371,6 +375,10 @@ es:
layer: Capa layer: Capa
layer_move: Movimientos del ratón layer_move: Movimientos del ratón
layer_click: Clics layer_click: Clics
devices:
mobile: Móvil
tablet: Tablet
desktop: Escritorio
index: index:
title: Analytics del sitio title: Analytics del sitio
lead: Heatmaps de movimientos/clics 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.
@@ -391,14 +399,24 @@ es:
scroll_hint: "Media %{avg}% · máximo %{max}%" scroll_hint: "Media %{avg}% · máximo %{max}%"
heatmap_moves: Mapa de movimientos heatmap_moves: Mapa de movimientos
heatmap_clicks: Mapa de clics heatmap_clicks: Mapa de clics
heatmap_hint: Superposición de color sobre la captura de la página. Verde = poco, rojo = mucho. heatmap_hint: Superposición de color sobre la captura %{device}. Verde = poco, rojo = mucho. Los datos no se mezclan entre dispositivos.
device_tabs_label: Dispositivo
preview_title: Captura de página preview_title: Captura de página
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id). preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id).
snapshot_missing: Aún no hay captura. Visita la página con consentimiento analytics (escritorio) y vuelve a intentarlo. snapshot_missing: Aún no hay captura para %{device}. Visita la página con ese dispositivo y consentimiento analytics, y vuelve a intentarlo.
snapshot_meta: "Captura %{device} · %{at}" snapshot_meta: "Captura %{device} · %{at}"
no_points: No hay puntos agregados para esta capa en el periodo. no_points: No hay puntos agregados para esta capa en el periodo.
heatmap_title: Mapa de clics heatmap_title: Mapa de clics
no_clicks: No hay clics agregados para esta página en el periodo. no_clicks: No hay clics agregados para esta página en el periodo.
preview:
title: Vista previa sin seguimiento
lead: Navega el sitio público sin registrar pageviews, clics, movimientos ni capturas heatmap. Útil para pruebas en producción.
active: Vista previa staff activa en este navegador (7 días o hasta desactivar).
enable: Activar vista previa staff
disable: Desactivar vista previa
open_site: Abrir sitio
enabled: Vista previa staff activada. Este navegador no registrará analytics.
disabled: Vista previa staff desactivada.
billing: billing:
index: index:
+20 -2
View File
@@ -11,6 +11,7 @@ fr:
youtube: YouTube youtube: YouTube
sessions: Sessions sessions: Sessions
analytics: Analytics analytics: Analytics
costs: Coûts
stream_nodes: Nœuds stream stream_nodes: Nœuds stream
password: Mot de passe password: Mot de passe
logout: Déconnexion logout: Déconnexion
@@ -50,6 +51,9 @@ fr:
announcement_created: Alerte enregistrée announcement_created: Alerte enregistrée
announcement_updated: Alerte mise à jour announcement_updated: Alerte mise à jour
announcement_destroyed: Alerte supprimée announcement_destroyed: Alerte supprimée
cost_entry_created: Poste de coût enregistré.
cost_entry_updated: Poste de coût mis à jour.
cost_entry_destroyed: Poste de coût supprimé.
common: common:
free_plan: Free free_plan: Free
yes: "Oui" yes: "Oui"
@@ -371,6 +375,10 @@ fr:
layer: Couche layer: Couche
layer_move: Mouvements souris layer_move: Mouvements souris
layer_click: Clics layer_click: Clics
devices:
mobile: Mobile
tablet: Tablette
desktop: Ordinateur
index: index:
title: Analytics du site title: Analytics du site
lead: Heatmaps mouvements/clics 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.
@@ -391,14 +399,24 @@ fr:
scroll_hint: "Moyenne %{avg}% · maximum %{max}%" scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
heatmap_moves: Carte des mouvements heatmap_moves: Carte des mouvements
heatmap_clicks: Carte des clics heatmap_clicks: Carte des clics
heatmap_hint: Superposition colorée sur la capture d’écran de la page. Vert = faible, rouge = fort. heatmap_hint: Superposition colorée sur la capture %{device}. Vert = faible, rouge = fort. Les données ne sont pas mélangées entre appareils.
device_tabs_label: Appareil
preview_title: Capture de page preview_title: Capture de page
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id). preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id).
snapshot_missing: Pas encore de capture. Visitez la page avec consentement analytics (desktop), puis réessayez. snapshot_missing: Pas encore de capture pour %{device}. Visitez la page sur cet appareil avec consentement analytics, puis réessayez.
snapshot_meta: "Capture %{device} · %{at}" snapshot_meta: "Capture %{device} · %{at}"
no_points: Aucun point agrégé pour cette couche sur la période. no_points: Aucun point agrégé pour cette couche sur la période.
heatmap_title: Carte des clics heatmap_title: Carte des clics
no_clicks: Aucun clic agrégé pour cette page sur la période. no_clicks: Aucun clic agrégé pour cette page sur la période.
preview:
title: Aperçu sans suivi
lead: Parcourez le site public sans enregistrer pages vues, clics, mouvements ni captures heatmap. Utile pour tester en production.
active: Aperçu staff actif sur ce navigateur (7 jours ou jusqu'à désactivation).
enable: Activer l'aperçu staff
disable: Désactiver l'aperçu
open_site: Ouvrir le site
enabled: Aperçu staff activé. Ce navigateur ne enregistrera pas d'analytics.
disabled: Aperçu staff désactivé.
billing: billing:
index: index:
+80 -2
View File
@@ -11,6 +11,7 @@ it:
youtube: YouTube youtube: YouTube
sessions: Sessioni sessions: Sessioni
analytics: Analytics analytics: Analytics
costs: Costi
stream_nodes: Nodi stream stream_nodes: Nodi stream
password: Password password: Password
logout: Esci logout: Esci
@@ -54,6 +55,9 @@ it:
announcement_created: Avviso salvato announcement_created: Avviso salvato
announcement_updated: Avviso aggiornato announcement_updated: Avviso aggiornato
announcement_destroyed: Avviso eliminato announcement_destroyed: Avviso eliminato
cost_entry_created: Voce di costo registrata.
cost_entry_updated: Voce di costo aggiornata.
cost_entry_destroyed: Voce di costo eliminata.
common: common:
free_plan: Free free_plan: Free
yes: "Sì" yes: "Sì"
@@ -392,6 +396,10 @@ it:
layer: Livello layer: Livello
layer_move: Movimenti mouse layer_move: Movimenti mouse
layer_click: Click layer_click: Click
devices:
mobile: Mobile
tablet: Tablet
desktop: Desktop
index: index:
title: Analytics sito title: Analytics sito
lead: Heatmap movimenti/click 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.
@@ -412,14 +420,84 @@ it:
scroll_hint: "Media %{avg}% · massimo %{max}%" scroll_hint: "Media %{avg}% · massimo %{max}%"
heatmap_moves: Mappa movimenti mouse heatmap_moves: Mappa movimenti mouse
heatmap_clicks: Mappa click heatmap_clicks: Mappa click
heatmap_hint: Overlay colorato sullo screenshot della pagina. Verde = poco, rosso = molto. heatmap_hint: Overlay colorato sullo screenshot %{device}. Verde = poco, rosso = molto. I dati non vengono mischiati tra dispositivi.
device_tabs_label: Dispositivo
preview_title: Screenshot pagina preview_title: Screenshot pagina
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id). preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id).
snapshot_missing: Nessuno screenshot ancora. Visita la pagina sul sito con consenso analytics (desktop) e riprova tra qualche secondo. snapshot_missing: Nessuno screenshot per %{device}. Visita la pagina sul sito con quel dispositivo e consenso analytics, poi riprova tra qualche secondo.
snapshot_meta: "Screenshot %{device} · catturato %{at}" snapshot_meta: "Screenshot %{device} · catturato %{at}"
no_points: Nessun punto aggregato per questo livello nel periodo. no_points: Nessun punto aggregato per questo livello nel periodo.
heatmap_title: Mappa click heatmap_title: Mappa click
no_clicks: Nessun click aggregato per questa pagina nel periodo. no_clicks: Nessun click aggregato per questa pagina nel periodo.
preview:
title: Anteprima senza tracciamento
lead: Visita il sito pubblico senza registrare pageview, click, movimenti o screenshot heatmap. Utile per test in produzione.
active: Modalità anteprima attiva su questo browser (7 giorni o fino a disattivazione).
enable: Attiva anteprima staff
disable: Disattiva anteprima
open_site: Apri sito
enabled: Anteprima staff attivata. Il sito non registrerà analytics su questo browser.
disabled: Anteprima staff disattivata.
costs:
index:
title: Analisi costi
lead: Inserisci i costi mensili della piattaforma e confronta costo, utilizzo e revenue. I KPI si ricalcolano automaticamente quando modifichi le voci.
month: Mese
apply: Applica
entries_title: Voci di costo del mese
entries_lead: Puoi aggiungere più voci (es. server, storage, banda). Il totale è la somma delle voci del mese.
add_entry: Aggiungi voce
no_entries: Nessuna voce di costo per questo mese. Aggiungi il costo della piattaforma per vedere i KPI.
clubs_title: Costo allocato per società
clubs_lead: Il costo infrastruttura è ripartito proporzionalmente alle ore trasmesse nel mese.
no_clubs: Nessuna diretta terminata in questo mese.
trends_title: Trend (ultimi 12 mesi)
kpi:
platform_cost: Costo piattaforma
revenue: Revenue incassata
margin: Margine
margin_pct: Margine %
hours: Ore trasmesse
sessions: Dirette
clubs_active: Società attive
storage: Storage replay
cost_per_hour: Costo / ora stream
cost_per_session: Costo / diretta
cost_per_club: Costo / società attiva
revenue_per_hour: Revenue / ora stream
cost_per_gb: Costo / GB storage
storage_note: Storage cumulativo attuale (non mensile).
charts:
cost_revenue: Costo vs revenue
cost_per_hour: Costo per ora trasmessa
hours: Ore trasmesse
table:
label: Voce
amount: Importo
notes: Note
club: Società
plan: Piano
hours: Ore
hours_share: Quota ore
allocated_cost: Costo allocato
revenue: Revenue
margin: Margine
cost_per_hour: €/ora
cost_per_session: €/diretta
storage: Storage
entries:
new_title: Nuova voce di costo
edit_title: Modifica voce di costo
month: Mese
label: Descrizione
label_hint: Es. Hetzner, nodi stream CPX, S3, banda egress.
amount: Importo (€)
notes: Note
save: Salva
cancel: Annulla
delete: Elimina
delete_confirm: Eliminare questa voce di costo?
billing: billing:
index: index:
+3
View File
@@ -18,6 +18,9 @@ de:
logout: Abmelden logout: Abmelden
account: Konto account: Konto
signup: Team registrieren signup: Team registrieren
analytics_preview:
badge: Staff-Vorschau — Analytics deaktiviert
manage: Admin
footer: footer:
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
live: Live live: Live
+3
View File
@@ -18,6 +18,9 @@ en:
logout: Log out logout: Log out
account: Account account: Account
signup: Register a team signup: Register a team
analytics_preview:
badge: Staff preview — analytics disabled
manage: Admin
footer: footer:
tagline: Every match, every event, for those who can't be there tagline: Every match, every event, for those who can't be there
live: Live live: Live
+3
View File
@@ -18,6 +18,9 @@ es:
logout: Salir logout: Salir
account: Cuenta account: Cuenta
signup: Registrar equipo signup: Registrar equipo
analytics_preview:
badge: Vista previa staff — analytics desactivados
manage: Admin
footer: footer:
tagline: Cada partido, cada evento, para quien no puede estar tagline: Cada partido, cada evento, para quien no puede estar
live: Directos live: Directos
+3
View File
@@ -18,6 +18,9 @@ fr:
logout: Déconnexion logout: Déconnexion
account: Compte account: Compte
signup: Inscrire une équipe signup: Inscrire une équipe
analytics_preview:
badge: Aperçu staff — analytics désactivés
manage: Admin
footer: footer:
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
live: Directs live: Directs
+3
View File
@@ -18,6 +18,9 @@ it:
logout: Esci logout: Esci
account: Account account: Account
signup: Registra squadra signup: Registra squadra
analytics_preview:
badge: Anteprima staff — analytics disattivati
manage: Admin
footer: footer:
tagline: Ogni partita, ogni evento, per chi non può esserci tagline: Ogni partita, ogni evento, per chi non può esserci
live: Dirette live: Dirette
+4
View File
@@ -130,8 +130,12 @@ Rails.application.routes.draw do
delete :clear_kill_switch delete :clear_kill_switch
end end
end end
get "costs", to: "costs#index", as: :costs
resources :cost_entries, path: "costs/entries", except: %i[index show]
get "analytics", to: "analytics#index", as: :analytics get "analytics", to: "analytics#index", as: :analytics
get "analytics/page", to: "analytics#show", as: :analytics_page get "analytics/page", to: "analytics#show", as: :analytics_page
post "analytics/preview", to: "analytics#preview_enable", as: :analytics_preview
delete "analytics/preview", to: "analytics#preview_disable"
get "youtube/platform", to: "youtube#platform", as: :youtube_platform get "youtube/platform", to: "youtube#platform", as: :youtube_platform
end end
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class CreatePlatformCostEntries < ActiveRecord::Migration[7.2]
def change
create_table :platform_cost_entries, id: :uuid do |t|
t.date :month, null: false
t.string :label, null: false, default: "Piattaforma produzione"
t.integer :amount_cents, null: false
t.text :notes
t.timestamps
end
add_index :platform_cost_entries, :month
add_index :platform_cost_entries, %i[month label]
end
end
+12 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) do ActiveRecord::Schema[7.2].define(version: 2026_08_28_180000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -313,6 +313,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) do
t.index ["slug"], name: "index_plans_on_slug", unique: true t.index ["slug"], name: "index_plans_on_slug", unique: true
end end
create_table "platform_cost_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.date "month", null: false
t.string "label", default: "Piattaforma produzione", null: false
t.integer "amount_cents", null: false
t.text "notes"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["month", "label"], name: "index_platform_cost_entries_on_month_and_label"
t.index ["month"], name: "index_platform_cost_entries_on_month"
end
create_table "recordings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "recordings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "stream_session_id", null: false t.uuid "stream_session_id", null: false
t.uuid "team_id", null: false t.uuid "team_id", null: false
+132
View File
@@ -0,0 +1,132 @@
# frozen_string_literal: true
# Dati simulati per la pagina Admin → Costi (trend 12 mesi, società, revenue).
# Uso: bundle exec rails runner db/seeds/cost_analytics_demo.rb
load Rails.root.join("db/seeds/plans.rb")
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
u.name = "Coach Demo"
u.password = "Password123"
u.role = "coach"
end
clubs_data = [
{ name: "Tigers Volley", sport: "volleyball", plan: "premium_full", sessions: 14, hours: 18.5, revenue_yearly: 19900 },
{ name: "ASD Eagles Milano", sport: "volleyball", plan: "premium_light", sessions: 9, hours: 11.0, revenue_yearly: 5900 },
{ name: "Volley Stars Roma", sport: "volleyball", plan: "premium_full", sessions: 6, hours: 8.0, revenue_yearly: 19900 },
{ name: "Basket Juventus U18", sport: "basketball", plan: "premium_light", sessions: 4, hours: 5.5, revenue_yearly: 5900 },
{ name: "Padova Beach", sport: "volleyball", plan: "free", sessions: 2, hours: 2.0, revenue_yearly: 0 }
]
sport_keys = {
"volleyball" => "pallavolo",
"basketball" => "basket"
}
clubs = clubs_data.map do |row|
club = Club.find_or_create_by!(name: row[:name]) { |c| c.sport = row[:sport] }
Billing::AssignPlan.call(club: club, plan_slug: row[:plan])
ClubMembership.find_or_create_by!(user: coach, club: club) { |m| m.role = "owner" }
team = club.teams.find_or_create_by!(name: "Prima squadra") do |t|
t.sport_key = sport_keys.fetch(row[:sport], "pallavolo")
end
{ club: club, team: team, **row }
end
month_costs = [
{ offset: 11, hetzner: 89.00, stream: 12.50, s3: 8.20 },
{ offset: 10, hetzner: 89.00, stream: 18.00, s3: 9.10 },
{ offset: 9, hetzner: 89.00, stream: 24.50, s3: 10.40 },
{ offset: 8, hetzner: 89.00, stream: 31.00, s3: 11.80 },
{ offset: 7, hetzner: 89.00, stream: 22.00, s3: 12.20 },
{ offset: 6, hetzner: 89.00, stream: 15.50, s3: 13.50 },
{ offset: 5, hetzner: 89.00, stream: 28.00, s3: 14.80 },
{ offset: 4, hetzner: 89.00, stream: 35.50, s3: 15.60 },
{ offset: 3, hetzner: 89.00, stream: 42.00, s3: 16.90 },
{ offset: 2, hetzner: 89.00, stream: 38.00, s3: 18.20 },
{ offset: 1, hetzner: 89.00, stream: 45.00, s3: 19.50 },
{ offset: 0, hetzner: 89.00, stream: 52.00, s3: 21.00 }
]
PlatformCostEntry.delete_all
month_costs.each do |row|
month = Time.zone.today.beginning_of_month - row[:offset].months
[
{ label: "Hetzner home + backup", amount: row[:hetzner] },
{ label: "Nodi stream CPX (overflow)", amount: row[:stream] },
{ label: "Garage/S3 replay storage", amount: row[:s3] }
].each do |entry|
PlatformCostEntry.create!(
month: month,
label: entry[:label],
amount_cents: (entry[:amount] * 100).round,
notes: "Dato demo seed"
)
end
end
# Sessioni e pagamenti nel mese corrente (distribuiti tra società)
current_month = Time.zone.today.beginning_of_month
clubs.each_with_index do |row, index|
club = row[:club]
team = row[:team]
sessions_count = row[:sessions]
total_secs = (row[:hours] * 3600).round
sessions_count.times do |n|
secs = (total_secs / sessions_count.to_f).round
day = current_month + (n % 27).days
started = day.change(hour: 10 + (n % 6), min: 0)
ended = started + secs.seconds
match = team.matches.create!(
opponent_name: "Avversario demo #{n + 1}",
sport_key: team.sport_key,
scheduled_at: started
)
StreamSession.create!(
match: match,
user: coach,
platform: "matchlivetv",
status: "ended",
started_at: started,
ended_at: ended,
total_duration_secs: secs
)
end
if row[:revenue_yearly].positive?
payment = Billing::Payment.find_or_initialize_by(club: club, paid_at: current_month + (index + 3).days)
payment.assign_attributes(
status: "paid",
provider: "stripe",
amount_cents: row[:revenue_yearly],
currency: "EUR",
plan_slug: row[:plan]
)
payment.save!
end
# Storage simulato (cumulativo)
sample_session = team.matches.last&.stream_sessions&.first
next unless sample_session
rec = Recording.find_or_initialize_by(stream_session: sample_session)
rec.assign_attributes(
team: team,
status: "ready",
privacy_status: "unlisted",
storage_backend: "s3",
storage_policy: "retained",
byte_size: (500_000_000 + index * 180_000_000),
duration_secs: 3600
)
rec.save!
end
puts "Cost analytics demo OK:"
puts " Voci costo: #{PlatformCostEntry.count} (12 mesi)"
puts " Sessioni demo mese corrente: #{StreamSession.where(status: 'ended', ended_at: current_month..current_month.end_of_month).count}"
puts " Admin: http://localhost:3000/admin/costs"
puts " Login admin: admin / AdminPass123"
+132
View File
@@ -0,0 +1,132 @@
(function () {
var trend = window.adminCostTrend || [];
var i18n = window.adminCostI18n || {};
if (!trend.length || typeof Chart === "undefined") return;
function monthLabel(monthStr) {
var parts = monthStr.split("-");
if (parts.length < 2) return monthStr;
var d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, 1);
return d.toLocaleDateString([], { month: "short", year: "2-digit" });
}
var labels = trend.map(function (row) {
return monthLabel(row.month);
});
var chartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
scales: {
x: {
ticks: { maxTicksLimit: 12, color: "#9a9aad", font: { size: 10 } },
grid: { color: "rgba(255,255,255,0.06)" }
},
y: {
beginAtZero: true,
ticks: { color: "#9a9aad", font: { size: 10 } },
grid: { color: "rgba(255,255,255,0.06)" }
}
},
plugins: { legend: { labels: { color: "#ccc", boxWidth: 12 } } }
};
var euroTicks = {
ticks: {
color: "#9a9aad",
font: { size: 10 },
callback: function (v) {
return "€" + v;
}
}
};
var costRevenueCanvas = document.getElementById("chart-cost-revenue");
if (costRevenueCanvas) {
new Chart(costRevenueCanvas, {
type: "bar",
data: {
labels: labels,
datasets: [
{
label: i18n.cost || "Cost",
data: trend.map(function (r) { return (r.cost_cents || 0) / 100; }),
backgroundColor: "rgba(229, 57, 53, 0.65)"
},
{
label: i18n.revenue || "Revenue",
data: trend.map(function (r) { return (r.revenue_cents || 0) / 100; }),
backgroundColor: "rgba(67, 160, 71, 0.65)"
},
{
label: i18n.margin || "Margin",
data: trend.map(function (r) { return (r.margin_cents || 0) / 100; }),
backgroundColor: "rgba(255, 193, 7, 0.55)"
}
]
},
options: {
...chartOptions,
scales: {
x: chartOptions.scales.x,
y: { ...chartOptions.scales.y, ...euroTicks }
}
}
});
}
var costHourCanvas = document.getElementById("chart-cost-hour");
if (costHourCanvas) {
new Chart(costHourCanvas, {
type: "line",
data: {
labels: labels,
datasets: [{
label: i18n.costPerHour || "Cost/hour",
data: trend.map(function (r) {
return r.cost_per_hour_cents ? r.cost_per_hour_cents / 100 : 0;
}),
borderColor: "#e53935",
backgroundColor: "rgba(229, 57, 53, 0.12)",
fill: true,
tension: 0.35,
pointRadius: 2,
borderWidth: 2
}]
},
options: {
...chartOptions,
scales: {
x: chartOptions.scales.x,
y: { ...chartOptions.scales.y, ...euroTicks }
},
plugins: { legend: { display: false } }
}
});
}
var hoursCanvas = document.getElementById("chart-hours");
if (hoursCanvas) {
new Chart(hoursCanvas, {
type: "line",
data: {
labels: labels,
datasets: [{
label: i18n.hours || "Hours",
data: trend.map(function (r) { return r.hours || 0; }),
borderColor: "#43a047",
backgroundColor: "rgba(67, 160, 71, 0.12)",
fill: true,
tension: 0.35,
pointRadius: 2,
borderWidth: 2
}]
},
options: {
...chartOptions,
plugins: { legend: { display: false } }
}
});
}
})();
+13
View File
@@ -693,6 +693,13 @@ body.admin-body {
background: #3a1515; background: #3a1515;
} }
.admin-device-tab-count {
margin-left: 0.35rem;
padding: 0 0.35rem;
font-size: 0.75rem;
color: var(--muted);
}
.admin-locale-panel { .admin-locale-panel {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -793,6 +800,12 @@ body.admin-body {
gap: 0.6rem; gap: 0.6rem;
} }
.admin-analytics-preview__status {
margin: 0 0 0.75rem;
color: #a5f0b8;
font-size: 0.9rem;
}
.admin-filter-count { .admin-filter-count {
font-size: 0.85rem; font-size: 0.85rem;
} }
+10
View File
@@ -37,6 +37,11 @@
return document.body && document.body.getAttribute("data-ga-id"); return document.body && document.body.getAttribute("data-ga-id");
} }
function isAnalyticsSuppressed() {
var meta = document.querySelector("meta[name='mltv-analytics-suppress']");
return meta && meta.getAttribute("content") === "1";
}
function loadGoogleAnalytics() { function loadGoogleAnalytics() {
var id = measurementId(); var id = measurementId();
if (!id || window.__mltvGaLoaded) return; if (!id || window.__mltvGaLoaded) return;
@@ -68,6 +73,7 @@
} }
function applyConsent(consent) { function applyConsent(consent) {
if (isAnalyticsSuppressed()) return;
if (consent && consent.analytics) { if (consent && consent.analytics) {
loadGoogleAnalytics(); loadGoogleAnalytics();
if (typeof window.mltvSiteAnalyticsStart === "function") { if (typeof window.mltvSiteAnalyticsStart === "function") {
@@ -94,6 +100,10 @@
if (acceptAll) { if (acceptAll) {
acceptAll.addEventListener("click", function () { acceptAll.addEventListener("click", function () {
if (isAnalyticsSuppressed()) {
hideBanner(banner);
return;
}
var c = writeConsent({ analytics: true }); var c = writeConsent({ analytics: true });
applyConsent(c); applyConsent(c);
hideBanner(banner); hideBanner(banner);
+28
View File
@@ -3549,3 +3549,31 @@ a.replay-archive__thumb:hover {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.mltv-preview-badge {
position: fixed;
bottom: 16px;
left: 16px;
z-index: 1200;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(20, 20, 28, 0.94);
border: 1px solid #444;
color: #ddd;
font-size: 0.82rem;
line-height: 1.3;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
.mltv-preview-badge__link {
color: #ff8a80;
text-decoration: none;
font-weight: 600;
}
.mltv-preview-badge__link:hover {
color: #ff5252;
}
+8 -2
View File
@@ -29,6 +29,11 @@
return false; return false;
} }
function isAnalyticsSuppressed() {
var meta = document.querySelector("meta[name='mltv-analytics-suppress']");
return meta && meta.getAttribute("content") === "1";
}
function hasAnalyticsConsent() { function hasAnalyticsConsent() {
try { try {
var raw = localStorage.getItem(STORAGE_KEY); var raw = localStorage.getItem(STORAGE_KEY);
@@ -104,7 +109,7 @@
function flush(useBeacon) { function flush(useBeacon) {
flushMovesIntoQueue(); flushMovesIntoQueue();
if (!queue.length || !hasAnalyticsConsent()) { if (!queue.length || !hasAnalyticsConsent() || isAnalyticsSuppressed()) {
queue = []; queue = [];
return; return;
} }
@@ -236,7 +241,7 @@
} }
function captureSnapshot() { function captureSnapshot() {
if (!hasAnalyticsConsent() || alreadyCapturedToday()) return; if (!hasAnalyticsConsent() || isAnalyticsSuppressed() || alreadyCapturedToday()) return;
loadHtml2Canvas(function (html2canvas) { loadHtml2Canvas(function (html2canvas) {
var size = pageSize(); var size = pageSize();
html2canvas(document.documentElement, { html2canvas(document.documentElement, {
@@ -281,6 +286,7 @@
function start() { function start() {
if (started) return; if (started) return;
if (excludedPath(location.pathname)) return; if (excludedPath(location.pathname)) return;
if (isAnalyticsSuppressed()) return;
if (!hasAnalyticsConsent()) return; if (!hasAnalyticsConsent()) return;
started = true; started = true;
maxScroll = scrollPct(); maxScroll = scrollPct();
+31 -1
View File
@@ -35,10 +35,40 @@ RSpec.describe "Admin analytics", type: :request do
expect(response.body).to include("40") expect(response.body).to include("40")
end end
it "mostra la heatmap di una pagina" do it "mostra la heatmap di una pagina con tab dispositivo" do
get admin_analytics_page_path, params: { page_path: "/prezzi" } get admin_analytics_page_path, params: { page_path: "/prezzi" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include("admin-heatmap") expect(response.body).to include("admin-heatmap")
expect(response.body).to include("/prezzi") expect(response.body).to include("/prezzi")
expect(response.body).to include("is-active")
expect(response.body).to include(I18n.t("admin.analytics.devices.desktop"))
end
it "mostra solo i punti del dispositivo selezionato" do
AnalyticsPageCell.create!(
day: Time.zone.today,
page_path: "/prezzi",
device: "mobile",
cell_x: 5,
cell_y: 8,
click_count: 9,
move_count: 0
)
get admin_analytics_page_path, params: { page_path: "/prezzi", device: "mobile", layer: "click" }
expect(response).to have_http_status(:ok)
expect(response.body).to include("x&quot;:5")
expect(response.body).to include("c&quot;:9")
expect(response.body).not_to include("x&quot;:10")
end
it "attiva e disattiva l'anteprima staff" do
post admin_analytics_preview_path, params: { return_to: "/" }
expect(response).to redirect_to("/")
expect(response.cookies[Analytics::Suppress::COOKIE_NAME]).to eq("1")
delete admin_analytics_preview_path
expect(response).to redirect_to(admin_analytics_path)
expect(response.cookies[Analytics::Suppress::COOKIE_NAME]).to be_nil
end end
end end
+44
View File
@@ -0,0 +1,44 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "Admin costs", type: :request do
let!(:admin) { AdminAccount.create!(username: "ops-costs", password: "Password123") }
let(:month) { Time.zone.today.beginning_of_month }
before do
post admin_login_path, params: { username: admin.username, password: "Password123" }
PlatformCostEntry.create!(month: month, label: "Piattaforma", amount_cents: 8_500)
end
it "mostra la dashboard costi" do
get admin_costs_path, params: { month: month.strftime("%Y-%m") }
expect(response).to have_http_status(:ok)
expect(response.body).to include(I18n.t("admin.costs.index.title"))
expect(response.body).to include("85.00")
end
it "crea, aggiorna ed elimina una voce di costo" do
post admin_cost_entries_path,
params: {
platform_cost_entry: {
month: month.strftime("%Y-%m"),
label: "Stream CPX",
amount_euros: "42.50",
notes: "overflow nodes"
}
}
expect(response).to redirect_to(admin_costs_path(month: month.strftime("%Y-%m")))
entry = PlatformCostEntry.find_by(label: "Stream CPX")
expect(entry.amount_cents).to eq(4_250)
patch admin_cost_entry_path(entry),
params: { platform_cost_entry: { month: month.strftime("%Y-%m"), label: "Stream CPX", amount_euros: "50" } }
expect(response).to redirect_to(admin_costs_path(month: month.strftime("%Y-%m")))
expect(entry.reload.amount_cents).to eq(5_000)
delete admin_cost_entry_path(entry)
expect(response).to redirect_to(admin_costs_path(month: month.strftime("%Y-%m")))
expect(PlatformCostEntry.find_by(id: entry.id)).to be_nil
end
end
@@ -52,6 +52,24 @@ RSpec.describe "Analytics ingest", type: :request do
expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:move_count)).to eq(12) expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:move_count)).to eq(12)
end end
it "ignora eventi con cookie anteprima staff" do
expect do
post "/analytics/events",
params: {
events: [
{ type: "pageview", path: "/prezzi-suppress", device: "desktop", ts: Time.current.to_i * 1000 }
]
},
headers: { "Cookie" => "#{Analytics::Suppress::COOKIE_NAME}=1" },
as: :json
end.not_to change { AnalyticsPageStat.where(page_path: "/prezzi-suppress").count }
expect(response).to have_http_status(:accepted)
body = JSON.parse(response.body)
expect(body["accepted"]).to eq(0)
expect(body["suppressed"]).to eq(true)
end
it "rifiuta path esclusi" do it "rifiuta path esclusi" do
post "/analytics/events", post "/analytics/events",
params: { params: {
@@ -29,6 +29,7 @@ RSpec.describe "Analytics snapshots", type: :request do
end end
it "rifiuta path esclusi" do it "rifiuta path esclusi" do
expect do
post "/analytics/snapshot", post "/analytics/snapshot",
params: { params: {
path: "/admin/analytics", path: "/admin/analytics",
@@ -37,8 +38,27 @@ RSpec.describe "Analytics snapshots", type: :request do
height: 900, height: 900,
image: jpeg_upload image: jpeg_upload
} }
end.not_to change(AnalyticsPageSnapshot, :count)
expect(response).to have_http_status(:unprocessable_content) expect(response).to have_http_status(:unprocessable_content)
expect(AnalyticsPageSnapshot.count).to eq(0) end
it "salta snapshot con cookie anteprima staff" do
expect do
post "/analytics/snapshot",
params: {
path: "/prezzi-suppress",
device: "desktop",
width: 1440,
height: 2200,
image: jpeg_upload
},
headers: { "Cookie" => "#{Analytics::Suppress::COOKIE_NAME}=1" }
end.not_to change(AnalyticsPageSnapshot, :count)
expect(response).to have_http_status(:accepted)
body = JSON.parse(response.body)
expect(body["skipped"]).to eq(true)
expect(body["suppressed"]).to eq(true)
end end
end end
@@ -0,0 +1,65 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Admin::CostAnalytics do
let!(:coach) do
User.create!(email: "cost-coach@test.it", name: "Coach", password: "Password123", role: "coach")
end
let!(:club) { Club.create!(name: "Tigers Club", sport: "volleyball") }
let!(:team) { club.teams.create!(name: "Tigers U16", sport_key: "pallavolo") }
let!(:match) { team.matches.create!(opponent_name: "Rival", sport_key: "pallavolo") }
let(:month) { Date.new(2024, 3, 1) }
before do
PlatformCostEntry.delete_all
PlatformCostEntry.create!(
month: month,
label: "Hetzner",
amount_cents: 10_000
)
StreamSession.create!(
match: match,
user: coach,
platform: "matchlivetv",
status: "ended",
started_at: month + 1.day + 10.hours,
ended_at: month + 1.day + 11.hours,
total_duration_secs: 3600
)
Billing::Payment.create!(
club: club,
amount_cents: 5_900,
currency: "EUR",
status: "paid",
provider: "stripe",
paid_at: month + 2.days
)
end
it "calcola KPI e allocazione per società" do
analytics = Admin::CostAnalytics.new(month: month)
summary = analytics.summary
expect(summary[:cost_cents]).to eq(10_000)
expect(summary[:sessions]).to eq(1)
expect(summary[:hours]).to eq(1.0)
expect(summary[:revenue_cents]).to eq(5_900)
expect(summary[:margin_cents]).to eq(-4_100)
expect(summary[:cost_per_hour_cents]).to eq(10_000)
clubs = analytics.club_breakdown
expect(clubs.size).to eq(1)
expect(clubs.first[:club_name]).to eq("Tigers Club")
expect(clubs.first[:allocated_cost_cents]).to eq(10_000)
expect(clubs.first[:revenue_cents]).to eq(5_900)
end
it "ricalcola dopo modifica delle voci di costo" do
PlatformCostEntry.create!(month: month, label: "S3", amount_cents: 2_000)
summary = Admin::CostAnalytics.new(month: month).summary
expect(summary[:cost_cents]).to eq(12_000)
expect(summary[:cost_per_session_cents]).to eq(12_000)
end
end