Compare commits

..
Author SHA1 Message Date
eminuxandCursor adfea01cdb Permette di filtrare il grafico analytics su una singola pagina.
Aggiunge il filtro Pagina (grafico) e il link Grafico in tabella, mantenendo Da/A/Device.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 22:14:35 +02:00
eminuxandCursor 81e7e3c9af Semplifica il grafico analytics: solo pageview e toggle nascondi.
Chiarisce che il totale è su tutte le pagine (filtri Da/A/Device), distinto dal dettaglio per path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 22:07:34 +02:00
Emiliano FrascaroandCursor 53ad26fdbe Alza iOS a build 38 per il rilascio da produzione.
La 37 era stata caricata da collaudo: App Store Connect richiede un numero di build nuovo dal branch di produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 21:58:09 +02:00
Emiliano FrascaroandCursor db0e842ca7 Prepara iOS 2.0.13 build 37 per lo Store con icona chiara e test del calendario.
Apple ha chiuso il treno 2.0.12: servono versione nuova, monogramma visibile sul nero e copertura dei casi dopo l'orario previsto.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 21:57:56 +02:00
eminuxandCursor 2e67e590d6 Aggiunge grafico andamento visite in admin Analytics.
Serie giornaliera di pageview/click/movimenti nel periodo filtrato, sopra la tabella pagine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 21:52:29 +02:00
eminuxandCursor bb342ead79 Bump Android a 2.0.13-native (versionCode 34) per Play Store.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:48:08 +02:00
eminuxandCursor ce81bb5789 Nasconde dall'hub le partite con risultato già inserito.
Restano avviabili solo le gare in ritardo senza punteggio, non quelle già terminate dal portale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 10:13:10 +02:00
eminuxandCursor 24c6ce4da0 Lascia trasmissibili le partite anche dopo l'orario previsto.
Se l'operatore è in ritardo deve ancora trovare la gara nell'app: restano in hub per tutta la giornata e, nei tornei aperti, fino alla fine dell'evento.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 10:07:23 +02:00
eminuxandCursor c3ef8e91a6 Rende usabili calendario, overlay ospite e stato diretta del cartellone.
Il calendario non deve più scorrere in orizzontale, il logo a destra non copre il nome e una diretta YouTube o in collegamento compare come In diretta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 09:55:08 +02:00
eminuxandCursor b09e83db09 Allinea stato e titolo replay del tabellone al risultato reale.
Una gara con punteggio non resta più «In programma» solo perché l'orario è futuro, e i replay usano le squadre del match invece della squadra di trasmissione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 09:15:10 +02:00
eminux 097e55df79 Merge branch 'feature/tornei' into collaudo 2026-09-05 15:06:24 +02:00
eminuxandCursor 7c7b2bf14c Alza soft capacity fase A e aggiunge quiet hours CPX notturne.
Home a 4 e cloud a 6 con MAX_NODES=12 (~76 soft); di notte (02–07 Europe/Rome) niente scale-out/warm-spare e sweeper che chiude i CPX idle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 09:48:50 +02:00
47 changed files with 1004 additions and 157 deletions
@@ -6,7 +6,8 @@ module Admin
@filters = { @filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date, from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today, to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence device: params[:device].presence,
chart_path: params[:chart_path].presence
} }
@analytics_preview_active = analytics_preview_active? @analytics_preview_active = analytics_preview_active?
@@ -36,6 +37,24 @@ module Admin
max_scroll: max_scroll_by_path[path].to_i max_scroll: max_scroll_by_path[path].to_i
} }
end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] } end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] }
@chart_path_options = @pages.map { |row| row[:page_path] }
if @filters[:chart_path].present? && @chart_path_options.exclude?(@filters[:chart_path])
@filters[:chart_path] = nil
end
trend_scope = scope
trend_scope = trend_scope.where(page_path: @filters[:chart_path]) if @filters[:chart_path].present?
pageviews_by_day = trend_scope.group(:day).sum(:pageview_count)
@trend = (@filters[:from]..@filters[:to]).map do |day|
{
day: day.iso8601,
pageviews: pageviews_by_day[day].to_i
}
end
@trend_totals = {
pageviews: @trend.sum { |r| r[:pageviews] }
}
end end
def show def show
@@ -10,7 +10,7 @@ module Api
def index def index
matches = @team.matches matches = @team.matches
.includes(:team, :stream_sessions, :home_participant, :away_participant) .includes(:team, :stream_sessions, :home_participant, :away_participant, :tournament)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc) .order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc)
unless current_user.club_admin?(@team.club) unless current_user.club_admin?(@team.club)
if @team.tournament_broadcast? if @team.tournament_broadcast?
@@ -16,7 +16,7 @@ module Public
Tournaments::Entitlements.new(@club).assert_writable! Tournaments::Entitlements.new(@club).assert_writable!
match = @tournament.matches.find(params[:match_id]) match = @tournament.matches.find(params[:match_id])
attrs = match_params attrs = match_params
if match.played? || match.result_status.to_s.start_with?("walkover") if match.result_recorded?
attrs = attrs.except(:home_participant_id, :away_participant_id) attrs = attrs.except(:home_participant_id, :away_participant_id)
end end
match.update!(attrs) match.update!(attrs)
@@ -38,19 +38,21 @@ module Public
.includes( .includes(
:tournament_group, :tournament_group,
:tournament_round, :tournament_round,
:stream_sessions,
home_participant: { logo_file_attachment: :blob }, home_participant: { logo_file_attachment: :blob },
away_participant: { logo_file_attachment: :blob } away_participant: { logo_file_attachment: :blob }
) )
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :asc) .order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :asc)
@live_sessions = StreamSession broadcasting = StreamSession
.broadcasting .broadcasting
.publicly_listed
.where(platform: "matchlivetv")
.includes(:score_state, match: [:home_participant, :away_participant, { team: :club }]) .includes(:score_state, match: [:home_participant, :away_participant, { team: :club }])
.where(match_id: @tournament.matches.select(:id)) .where(match_id: @tournament.matches.select(:id))
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc) .order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
@live_by_match_id = @live_sessions.index_by(&:match_id) .to_a
@live_by_match_id = {}
broadcasting.each { |session| @live_by_match_id[session.match_id] ||= session }
@live_sessions = broadcasting.select(&:public_watchable?)
@recordings = Recording.ready.publicly_listed @recordings = Recording.ready.publicly_listed
.joins(stream_session: :match) .joins(stream_session: :match)
@@ -67,7 +67,7 @@ module Public
sessions = match.stream_sessions.to_a sessions = match.stream_sessions.to_a
latest = sessions.max_by(&:created_at) latest = sessions.max_by(&:created_at)
if latest && !latest.status.in?(%w[ended error]) if latest && !latest.status.in?(%w[ended error])
return :live if latest.status.in?(%w[live reconnecting paused]) return :live if latest.status.in?(%w[live reconnecting paused connecting])
return :waiting_operator return :waiting_operator
end end
@@ -100,12 +100,24 @@ module Public
def tournament_public_board_state(match) def tournament_public_board_state(match)
return :live if tournament_public_live_session(match) return :live if tournament_public_live_session(match)
return :replay if tournament_public_recording(match) return :replay if tournament_public_recording(match)
return :ended if match.result_recorded?
return :scheduled if match.scheduled_upcoming? return :scheduled if match.scheduled_upcoming?
return :ended if match.played? || match.home_score.present?
:waiting :waiting
end end
def tournament_public_watch_target(session)
return unless session
if session.matchlivetv_platform? && session.publicly_listed?
return [public_live_path(session), t("tournaments.page.watch_live"), {}]
end
if session.youtube_watch_url.present?
return [session.youtube_watch_url, t("live.show.youtube_watch_link"), { target: "_blank", rel: "noopener" }]
end
nil
end
def tournament_public_score_label(match) def tournament_public_score_label(match)
return "#{match.home_score}#{match.away_score}" if match.home_score.present? && match.away_score.present? return "#{match.home_score}#{match.away_score}" if match.home_score.present? && match.away_score.present?
@@ -117,7 +129,7 @@ module Public
end end
def tournament_match_sides_locked?(match) def tournament_match_sides_locked?(match)
match.played? || match.result_status.to_s.start_with?("walkover") match.result_recorded?
end end
def tournament_phase_label(match) def tournament_phase_label(match)
+15 -2
View File
@@ -94,14 +94,27 @@ class Match < ApplicationRecord
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
end end
def result_recorded?
played? || result_status.to_s.start_with?("walkover") || (home_score.present? && away_score.present?)
end
def coach_hub_visible? def coach_hub_visible?
active = active_stream_session active = active_stream_session
return true if active&.resumable? return true if active&.resumable?
return true if active&.idle? return true if active&.idle?
return false if result_recorded?
return false if stream_completed? return false if stream_completed?
return true if scheduled_on_calendar?
return true if tournament_open_for_late_stream?
scheduled_upcoming? false
end
def tournament_open_for_late_stream?
return false unless tournament_match?
event = tournament
event.present? && !event.archived? && event.ends_on >= Date.current
end end
def effective_board_type def effective_board_type
+22 -2
View File
@@ -146,14 +146,22 @@ class Recording < ApplicationRecord
end end
def title_or_default def title_or_default
title.presence || default_title match = stream_session&.match
return title.presence || "Replay" unless match
generated = match.matchup_label
stored = title.to_s.strip
return generated if stored.blank?
return generated if stale_auto_title?(match, stored)
stored
end end
def default_title def default_title
match = stream_session&.match match = stream_session&.match
return "Replay" unless match return "Replay" unless match
"#{match.team.name} vs #{match.opponent_name}" match.matchup_label
end end
def recorded_at_or_fallback def recorded_at_or_fallback
@@ -222,6 +230,18 @@ class Recording < ApplicationRecord
private private
def stale_auto_title?(match, stored)
return false unless match.tournament_match?
team_name = match.team&.name.to_s
return false if team_name.blank?
[
"#{team_name} vs #{match.opponent_name}",
"#{team_name} vs #{match.away_display_name}"
].include?(stored)
end
def normalize_privacy_status def normalize_privacy_status
self.privacy_status = "public" if privacy_status == "private" self.privacy_status = "public" if privacy_status == "private"
self.privacy_status = "unlisted" if privacy_status.blank? self.privacy_status = "unlisted" if privacy_status.blank?
+6
View File
@@ -186,6 +186,12 @@ class StreamSession < ApplicationRecord
platform == "youtube" && youtube_watch_url.present? platform == "youtube" && youtube_watch_url.present?
end end
def public_watchable?
return true if matchlivetv_platform? && publicly_listed?
youtube_ready?
end
def link_only? def link_only?
privacy_status.in?(%w[unlisted private]) privacy_status.in?(%w[unlisted private])
end end
@@ -73,7 +73,7 @@ module Recordings
def default_title def default_title
match = @session.match match = @session.match
"#{match.team.name} vs #{match.opponent_name}" match.matchup_label.presence || "Replay"
end end
def privacy_from_session def privacy_from_session
@@ -50,6 +50,14 @@
@filters[:device] @filters[:device]
) %> ) %>
</label> </label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.chart_path") %></span>
<%= select_tag :chart_path,
options_for_select(
[[t("admin.analytics.filters.chart_path_all"), ""]] + @chart_path_options.map { |p| [p, p] },
@filters[:chart_path]
) %>
</label>
</div> </div>
<div class="admin-filter-actions"> <div class="admin-filter-actions">
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %> <%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
@@ -58,8 +66,66 @@
<% end %> <% end %>
</div> </div>
<% if @trend.any? && @trend_totals[:pageviews].positive? %>
<section class="panel admin-analytics-trend" id="admin-analytics-trend">
<div class="admin-analytics-trend__head">
<div>
<h3><%= t("admin.analytics.index.trend_title") %></h3>
<p class="muted admin-table-sub">
<% if @filters[:chart_path].present? %>
<%= t("admin.analytics.index.trend_lead_path", path: @filters[:chart_path]) %>
<% else %>
<%= t("admin.analytics.index.trend_lead") %>
<% end %>
</p>
</div>
<button type="button"
id="admin-analytics-trend-toggle"
class="admin-btn admin-btn--outline admin-btn--sm"
aria-controls="admin-analytics-trend-body"
aria-expanded="true"><%= t("admin.analytics.index.hide_chart") %></button>
</div>
<div id="admin-analytics-trend-body">
<div class="kpi-grid admin-analytics-trend__kpi">
<div class="kpi">
<div class="kpi-label">
<% if @filters[:chart_path].present? %>
<%= t("admin.analytics.index.trend_total_label_path") %>
<% else %>
<%= t("admin.analytics.index.trend_total_label") %>
<% end %>
</div>
<div class="kpi-value"><%= @trend_totals[:pageviews] %></div>
<div class="kpi-sub muted">
<% if @filters[:chart_path].present? %>
<code class="admin-mono"><%= @filters[:chart_path] %></code>
·
<% end %>
<%= t("admin.analytics.index.trend_total_hint", from: l(@filters[:from]), to: l(@filters[:to])) %>
</div>
</div>
</div>
<div class="chart-wrap chart-wrap--analytics">
<canvas id="chart-analytics-trend" aria-label="<%= t("admin.analytics.index.trend_title") %>"></canvas>
</div>
</div>
</section>
<script>
window.adminAnalyticsTrend = <%= raw @trend.to_json %>;
window.adminAnalyticsI18n = {
pageviews: <%= raw t("admin.analytics.index.table.pageviews").to_json %>,
hideChart: <%= raw t("admin.analytics.index.hide_chart").to_json %>,
showChart: <%= raw t("admin.analytics.index.show_chart").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-analytics.js?v=3" defer></script>
<% end %>
<div class="panel"> <div class="panel">
<% if @pages.any? %> <% if @pages.any? %>
<h3 class="admin-analytics-pages-title"><%= t("admin.analytics.index.pages_title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.index.pages_lead") %></p>
<div class="admin-table-wrap"> <div class="admin-table-wrap">
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
@@ -76,14 +142,18 @@
<tbody> <tbody>
<% @pages.each do |row| %> <% @pages.each do |row| %>
<% avg = row[:scroll_samples].positive? ? (row[:scroll_sum].to_f / row[:scroll_samples]).round : 0 %> <% avg = row[:scroll_samples].positive? ? (row[:scroll_sum].to_f / row[:scroll_samples]).round : 0 %>
<tr> <% chart_params = { from: @filters[:from], to: @filters[:to], chart_path: row[:page_path] } %>
<% chart_params[:device] = @filters[:device] if @filters[:device].present? %>
<tr class="<%= "is-chart-focus" if @filters[:chart_path] == row[:page_path] %>">
<td><code class="admin-mono"><%= row[:page_path] %></code></td> <td><code class="admin-mono"><%= row[:page_path] %></code></td>
<td><%= row[:pageviews] %></td> <td><%= row[:pageviews] %></td>
<td><%= row[:moves] %></td> <td><%= row[:moves] %></td>
<td><%= row[:clicks] %></td> <td><%= row[:clicks] %></td>
<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 class="admin-analytics-row-actions">
<%= link_to t("admin.analytics.index.chart_for_path"), admin_analytics_path(chart_params) %>
·
<% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %> <% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %>
<% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %> <% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %>
<%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %> <%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<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=16"> <link rel="stylesheet" href="/admin.css?v=19">
<%= yield :head %> <%= 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">
+2 -2
View File
@@ -10,7 +10,7 @@
<%= render "shared/analytics_suppress" %> <%= 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=101"> <link rel="stylesheet" href="/marketing.css?v=102">
</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" %>
@@ -24,7 +24,7 @@
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %> <%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
<script src="/branding-form.js?v=1" defer></script> <script src="/branding-form.js?v=1" defer></script>
<script src="/tournament-invite-editor.js?v=2" defer></script> <script src="/tournament-invite-editor.js?v=2" defer></script>
<script src="/tournament-calendar.js?v=1" defer></script> <script src="/tournament-calendar.js?v=2" defer></script>
<script src="/roster-form.js?v=1" defer></script> <script src="/roster-form.js?v=1" defer></script>
<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">
@@ -8,7 +8,7 @@
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= render "shared/analytics_suppress" %> <%= 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=101"> <link rel="stylesheet" href="/marketing.css?v=102">
<link rel="stylesheet" href="/live.css?v=26"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</head> </head>
@@ -37,8 +37,12 @@
</span> </span>
</td> </td>
<td class="tournament-board__actions"> <td class="tournament-board__actions">
<% if live_session %> <% watch = tournament_public_watch_target(live_session) %>
<%= link_to t("tournaments.page.watch_live"), public_live_path(live_session), class: "tournament-board__cta tournament-board__cta--live" %> <% if watch %>
<% url, label, html_opts = watch %>
<%= link_to label, url, { class: "tournament-board__cta tournament-board__cta--live" }.merge(html_opts) %>
<% elsif live_session %>
<span class="muted"><%= t("tournaments.page.no_media") %></span>
<% elsif recording %> <% elsif recording %>
<%= link_to t("tournaments.page.watch_replay"), public_replay_path(recording.stream_session_id), class: "tournament-board__cta" %> <%= link_to t("tournaments.page.watch_replay"), public_replay_path(recording.stream_session_id), class: "tournament-board__cta" %>
<% else %> <% else %>
@@ -52,8 +52,11 @@
<span class="replay-card__play" aria-hidden="true"></span> <span class="replay-card__play" aria-hidden="true"></span>
</div> </div>
<div class="replay-card__body"> <div class="replay-card__body">
<strong class="replay-card__title"><%= rec.title.presence || match.matchup_label %></strong> <% phase = [match.tournament_round&.name, match.tournament_group&.name].compact.first %>
<p class="replay-card__meta"><%= match.matchup_label %></p> <strong class="replay-card__title"><%= match.matchup_label %></strong>
<p class="replay-card__meta">
<%= [phase, rec.recorded_at_or_fallback && l(rec.recorded_at_or_fallback, format: :short)].compact.join(" · ") %>
</p>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
@@ -64,7 +64,11 @@
<span class="badge badge-live"><%= t("live.index.badge_live") %></span> <span class="badge badge-live"><%= t("live.index.badge_live") %></span>
<% end %> <% end %>
</div> </div>
<%= link_to t("tournaments.page.watch_live"), public_live_path(session), class: "btn-watch" %> <% watch = tournament_public_watch_target(session) %>
<% if watch %>
<% url, label, html_opts = watch %>
<%= link_to label, url, { class: "btn-watch" }.merge(html_opts) %>
<% end %>
</article> </article>
<% end %> <% end %>
</div> </div>
@@ -8,88 +8,105 @@
<% if @matches.any? %> <% if @matches.any? %>
<p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.calendar_edit_hint") %></p> <p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.calendar_edit_hint") %></p>
<div class="tournament-table-wrap"> <div class="tournament-match-list tournament-match-table" data-swap-error="<%= t("flash.tournaments.sides_swap_failed") %>">
<table class="data tournament-match-table" data-swap-error="<%= t("flash.tournaments.sides_swap_failed") %>"> <% @matches.each do |match| %>
<thead> <% form_id = "match-edit-#{match.id}" %>
<tr> <% courts = (@tournament.court_list + [match.court]).compact_blank.uniq %>
<th><%= t("tournaments.hub.datetime") %></th> <%= form_with url: public_tournament_match_path(@tournament, match), method: :patch, html: { id: form_id, class: "visually-hidden" } do %>
<th><%= t("tournaments.hub.court") %></th> <%= hidden_field_tag :tab, @tab, form: form_id %>
<th><%= t("tournaments.hub.round") %></th> <% end %>
<th><%= t("tournaments.hub.home") %></th> <article class="tournament-match-row">
<th class="tournament-match-swap-col"></th> <% if @writable %>
<th><%= t("tournaments.hub.away") %></th> <div class="tournament-match-row__field">
<th><%= t("tournaments.hub.result") %></th> <label for="match_at_<%= match.id %>"><%= t("tournaments.hub.datetime") %></label>
<th></th> <%= datetime_local_field_tag "match[scheduled_at]", tournament_datetime_local(match.scheduled_at),
</tr> id: "match_at_#{match.id}", form: form_id, required: true, class: "tournament-match-form__datetime" %>
</thead> </div>
<tbody> <div class="tournament-match-row__field">
<% @matches.each do |match| %> <label for="match_court_<%= match.id %>"><%= t("tournaments.hub.court") %></label>
<% form_id = "match-edit-#{match.id}" %> <%= select_tag "match[court]", options_for_select(courts, match.court),
<% courts = (@tournament.court_list + [match.court]).compact_blank.uniq %> id: "match_court_#{match.id}", form: form_id, include_blank: true, class: "tournament-match-form__court" %>
<%= form_with url: public_tournament_match_path(@tournament, match), method: :patch, html: { id: form_id, class: "visually-hidden" } do %> </div>
<%= hidden_field_tag :tab, @tab, form: form_id %> <div class="tournament-match-row__field tournament-match-row__field--phase">
<% end %> <span class="tournament-match-row__label"><%= t("tournaments.hub.round") %></span>
<tr> <p class="tournament-match-row__phase"><%= tournament_phase_label(match) %></p>
<% if @writable %> </div>
<td> <div class="tournament-match-row__sides">
<%= datetime_local_field_tag "match[scheduled_at]", tournament_datetime_local(match.scheduled_at), <div class="tournament-match-row__side">
id: "match_at_#{match.id}", form: form_id, required: true, class: "tournament-match-form__datetime" %> <span class="tournament-match-row__label"><%= t("tournaments.hub.home") %></span>
</td> <div data-swap-side="home"><%= render "public/tournaments/match_side_select", match: match, side: :home, form_id: form_id %></div>
<td> </div>
<%= select_tag "match[court]", options_for_select(courts, match.court), <div class="tournament-match-swap-col">
id: "match_court_#{match.id}", form: form_id, include_blank: true, class: "tournament-match-form__court" %> <%= button_to public_swap_tournament_match_path(@tournament, match), method: :post,
</td> class: "tournament-match-swap__btn",
<td><%= tournament_phase_label(match) %></td> title: t("tournaments.hub.swap_sides"),
<td data-swap-side="home"><%= render "public/tournaments/match_side_select", match: match, side: :home, form_id: form_id %></td> form: { class: "tournament-match-swap__form", data: { turbo: false } } do %>
<td class="tournament-match-swap-col"> <i class="fa-solid fa-right-left" aria-hidden="true"></i>
<%= button_to public_swap_tournament_match_path(@tournament, match), method: :post, <span class="visually-hidden"><%= t("tournaments.hub.swap_sides") %></span>
class: "tournament-match-swap__btn", <% end %>
title: t("tournaments.hub.swap_sides"), </div>
form: { class: "tournament-match-swap__form", data: { turbo: false } } do %> <div class="tournament-match-row__side">
<i class="fa-solid fa-right-left" aria-hidden="true"></i> <span class="tournament-match-row__label"><%= t("tournaments.hub.away") %></span>
<span class="visually-hidden"><%= t("tournaments.hub.swap_sides") %></span> <div data-swap-side="away"><%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %></div>
<% end %> </div>
</td> <div class="tournament-match-row__score">
<td data-swap-side="away"><%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %></td> <span class="tournament-match-row__label"><%= t("tournaments.hub.result") %></span>
<td> <% if match.result_recorded? %>
<% if match.played? || match.result_status.to_s.start_with?("walkover") %> <p data-swap-played-score><%= match.home_score %><%= match.away_score %></p>
<span data-swap-played-score><%= match.home_score %><%= match.away_score %></span> <% else %>
<% else %> <span class="tournament-match-form__score">
<span class="tournament-match-form__score"> <%= number_field_tag :home_score, match.home_score, id: "home_score_#{match.id}", form: form_id, data: { swap_score: "home" } %>
<%= number_field_tag :home_score, match.home_score, id: "home_score_#{match.id}", form: form_id, data: { swap_score: "home" } %> <span></span>
<span></span> <%= number_field_tag :away_score, match.away_score, id: "away_score_#{match.id}", form: form_id, data: { swap_score: "away" } %>
<%= number_field_tag :away_score, match.away_score, id: "away_score_#{match.id}", form: form_id, data: { swap_score: "away" } %> </span>
</span> <% end %>
<% end %> </div>
</td> </div>
<td class="tournament-match-form__actions"> <div class="tournament-match-form__actions tournament-match-row__actions">
<%= submit_tag t("tournaments.hub.save_match"), form: form_id, class: "btn btn-secondary tournament-match-form__save" %> <%= submit_tag t("tournaments.hub.save_match"), form: form_id, class: "btn btn-secondary tournament-match-form__save" %>
<% if match.deletable? %> <% if match.deletable? %>
<%= button_to t("matches.index.delete"), public_tournament_match_path(@tournament, match), <%= button_to t("matches.index.delete"), public_tournament_match_path(@tournament, match),
method: :delete, class: "btn btn-secondary tournament-match-form__save", method: :delete, class: "btn btn-secondary tournament-match-form__save",
form: { class: "tournament-match-delete-form", data: { turbo_confirm: match.matchup_label } } %> form: { class: "tournament-match-delete-form", data: { turbo_confirm: match.matchup_label } } %>
<% end %> <% end %>
</td> </div>
<% else %> <% else %>
<td><%= match.scheduled_at ? l(match.scheduled_at, format: :short) : "—" %></td> <div class="tournament-match-row__field">
<td><%= match.court || "—" %></td> <span class="tournament-match-row__label"><%= t("tournaments.hub.datetime") %></span>
<td><%= tournament_phase_label(match) %></td> <p class="tournament-match-row__phase"><%= match.scheduled_at ? l(match.scheduled_at, format: :short) : "—" %></p>
<td><%= tournament_team_chip(match.home_participant, name: match.home_display_name) %></td> </div>
<td class="tournament-match-swap-col"></td> <div class="tournament-match-row__field">
<td><%= tournament_team_chip(match.away_participant, name: match.away_display_name) %></td> <span class="tournament-match-row__label"><%= t("tournaments.hub.court") %></span>
<td> <p class="tournament-match-row__phase"><%= match.court || "—" %></p>
</div>
<div class="tournament-match-row__field tournament-match-row__field--phase">
<span class="tournament-match-row__label"><%= t("tournaments.hub.round") %></span>
<p class="tournament-match-row__phase"><%= tournament_phase_label(match) %></p>
</div>
<div class="tournament-match-row__sides">
<div class="tournament-match-row__side">
<span class="tournament-match-row__label"><%= t("tournaments.hub.home") %></span>
<%= tournament_team_chip(match.home_participant, name: match.home_display_name) %>
</div>
<div class="tournament-match-swap-col" aria-hidden="true"></div>
<div class="tournament-match-row__side">
<span class="tournament-match-row__label"><%= t("tournaments.hub.away") %></span>
<%= tournament_team_chip(match.away_participant, name: match.away_display_name) %>
</div>
<div class="tournament-match-row__score">
<span class="tournament-match-row__label"><%= t("tournaments.hub.result") %></span>
<p>
<% if match.home_score.present? %> <% if match.home_score.present? %>
<%= match.home_score %><%= match.away_score %> <%= match.home_score %><%= match.away_score %>
<% else %> <% else %>
<% end %> <% end %>
</td> </p>
<td></td> </div>
<% end %> </div>
</tr>
<% end %> <% end %>
</tbody> </article>
</table> <% end %>
</div> </div>
<% else %> <% else %>
<p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.no_matches") %></p> <p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.no_matches") %></p>
+13
View File
@@ -415,6 +415,8 @@ de:
any: Alle any: Alle
apply: Filtern apply: Filtern
reset: Zurücksetzen reset: Zurücksetzen
chart_path: Seite (Diagramm)
chart_path_all: Alle Seiten
layer: Ebene layer: Ebene
layer_move: Mausbewegungen layer_move: Mausbewegungen
layer_click: Klicks layer_click: Klicks
@@ -427,6 +429,17 @@ de:
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.
none: Keine Daten im gewählten Zeitraum. none: Keine Daten im gewählten Zeitraum.
heatmap: Heatmap heatmap: Heatmap
chart_for_path: Diagramm
trend_title: Pageview-Verlauf
trend_lead: Gesamte Pageviews aller Seiten, Tag für Tag. Dieselben Filter Von / Bis / Gerät. Seite im Filter wählen oder «Diagramm» in der Tabelle.
trend_lead_path: "Tägliche Pageviews nur für %{path} (gleiche Filter Von / Bis / Gerät)."
trend_total_label: Pageviews gesamt (alle Seiten)
trend_total_label_path: Pageviews der gewählten Seite
trend_total_hint: "%{from} → %{to}"
hide_chart: Diagramm ausblenden
show_chart: Diagramm anzeigen
pages_title: Aufschlüsselung nach Seite
pages_lead: Pageviews und Engagement im gefilterten Zeitraum, nach Pfad. «Diagramm» zeigt den Verlauf einer Seite.
table: table:
path: Seite path: Seite
pageviews: Pageviews pageviews: Pageviews
+13
View File
@@ -415,6 +415,8 @@ en:
any: All any: All
apply: Filter apply: Filter
reset: Reset reset: Reset
chart_path: Page (chart)
chart_path_all: All pages
layer: Layer layer: Layer
layer_move: Mouse moves layer_move: Mouse moves
layer_click: Clicks layer_click: Clicks
@@ -427,6 +429,17 @@ en:
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.
none: No data in the selected period. none: No data in the selected period.
heatmap: Heatmap heatmap: Heatmap
chart_for_path: Chart
trend_title: Pageview trend
trend_lead: Total pageviews across all pages, day by day. Uses the same From / To / Device filters. Pick a page in the filter or click «Chart» in the table.
trend_lead_path: "Day-by-day pageviews for %{path} only (same From / To / Device filters)."
trend_total_label: Total pageviews (all pages)
trend_total_label_path: Pageviews for selected page
trend_total_hint: "%{from} → %{to}"
hide_chart: Hide chart
show_chart: Show chart
pages_title: Breakdown by page
pages_lead: Pageviews and engagement for the filtered period, split by path. Use «Chart» to see one page over time.
table: table:
path: Page path: Page
pageviews: Pageviews pageviews: Pageviews
+13
View File
@@ -415,6 +415,8 @@ es:
any: Todos any: Todos
apply: Filtrar apply: Filtrar
reset: Restablecer reset: Restablecer
chart_path: Página (gráfico)
chart_path_all: Todas las páginas
layer: Capa layer: Capa
layer_move: Movimientos del ratón layer_move: Movimientos del ratón
layer_click: Clics layer_click: Clics
@@ -427,6 +429,17 @@ es:
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.
none: No hay datos en el periodo seleccionado. none: No hay datos en el periodo seleccionado.
heatmap: Heatmap heatmap: Heatmap
chart_for_path: Gráfico
trend_title: Evolución de pageviews
trend_lead: Total de pageviews de todas las páginas, día a día. Usa los mismos filtros Desde / Hasta / Device. Elige una página en el filtro o pulsa «Gráfico» en la tabla.
trend_lead_path: "Pageviews día a día solo para %{path} (mismos filtros Desde / Hasta / Device)."
trend_total_label: Pageviews totales (todas las páginas)
trend_total_label_path: Pageviews de la página seleccionada
trend_total_hint: "%{from} → %{to}"
hide_chart: Ocultar gráfico
show_chart: Mostrar gráfico
pages_title: Detalle por página
pages_lead: Pageviews y engagement en el periodo filtrado, por path. Usa «Gráfico» para ver una sola página en el tiempo.
table: table:
path: Página path: Página
pageviews: Pageviews pageviews: Pageviews
+13
View File
@@ -415,6 +415,8 @@ fr:
any: Tous any: Tous
apply: Filtrer apply: Filtrer
reset: Réinitialiser reset: Réinitialiser
chart_path: Page (graphique)
chart_path_all: Toutes les pages
layer: Couche layer: Couche
layer_move: Mouvements souris layer_move: Mouvements souris
layer_click: Clics layer_click: Clics
@@ -427,6 +429,17 @@ fr:
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.
none: Aucune donnée sur la période sélectionnée. none: Aucune donnée sur la période sélectionnée.
heatmap: Heatmap heatmap: Heatmap
chart_for_path: Graphique
trend_title: Évolution des pages vues
trend_lead: Total des pages vues sur toutes les pages, jour par jour. Mêmes filtres De / À / Appareil. Choisissez une page dans le filtre ou cliquez « Graphique » dans le tableau.
trend_lead_path: "Pages vues jour par jour pour %{path} uniquement (mêmes filtres De / À / Appareil)."
trend_total_label: Pages vues totales (toutes les pages)
trend_total_label_path: Pages vues de la page sélectionnée
trend_total_hint: "%{from} → %{to}"
hide_chart: Masquer le graphique
show_chart: Afficher le graphique
pages_title: Détail par page
pages_lead: Pages vues et engagement sur la période filtrée, par chemin. « Graphique » montre l’évolution dune page.
table: table:
path: Page path: Page
pageviews: Pages vues pageviews: Pages vues
+13
View File
@@ -436,6 +436,8 @@ it:
any: Tutti any: Tutti
apply: Filtra apply: Filtra
reset: Azzera reset: Azzera
chart_path: Pagina (grafico)
chart_path_all: Tutte le pagine
layer: Livello layer: Livello
layer_move: Movimenti mouse layer_move: Movimenti mouse
layer_click: Click layer_click: Click
@@ -448,6 +450,17 @@ it:
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.
none: Nessun dato nel periodo selezionato. none: Nessun dato nel periodo selezionato.
heatmap: Heatmap heatmap: Heatmap
chart_for_path: Grafico
trend_title: Andamento pageview
trend_lead: Totale pageview di tutte le pagine, giorno per giorno. Usa gli stessi filtri Da / A / Device. Scegli una pagina nel filtro oppure clicca «Grafico» in tabella.
trend_lead_path: "Pageview giorno per giorno solo per %{path} (stessi filtri Da / A / Device)."
trend_total_label: Pageview totali (tutte le pagine)
trend_total_label_path: Pageview della pagina selezionata
trend_total_hint: "%{from} → %{to}"
hide_chart: Nascondi grafico
show_chart: Mostra grafico
pages_title: Dettaglio per pagina
pages_lead: Pageview e engagement nel periodo filtrato, spezzati per path. Usa «Grafico» per vedere landamento di una sola pagina.
table: table:
path: Pagina path: Pagina
pageviews: Pageview pageviews: Pageview
+89
View File
@@ -0,0 +1,89 @@
(function () {
var STORAGE_KEY = "mltv_admin_analytics_chart_hidden";
var trend = window.adminAnalyticsTrend || [];
var i18n = window.adminAnalyticsI18n || {};
var panel = document.getElementById("admin-analytics-trend");
var canvas = document.getElementById("chart-analytics-trend");
var toggle = document.getElementById("admin-analytics-trend-toggle");
var body = document.getElementById("admin-analytics-trend-body");
function setHidden(hidden) {
if (!panel || !body || !toggle) return;
body.hidden = hidden;
panel.classList.toggle("is-collapsed", hidden);
toggle.setAttribute("aria-expanded", hidden ? "false" : "true");
toggle.textContent = hidden
? (i18n.showChart || "Mostra grafico")
: (i18n.hideChart || "Nascondi grafico");
try {
localStorage.setItem(STORAGE_KEY, hidden ? "1" : "0");
} catch (e) { /* ignore */ }
}
function initiallyHidden() {
try {
return localStorage.getItem(STORAGE_KEY) === "1";
} catch (e) {
return false;
}
}
if (toggle && body) {
setHidden(initiallyHidden());
toggle.addEventListener("click", function () {
setHidden(!body.hidden);
});
}
if (!canvas || !trend.length || typeof Chart === "undefined") return;
function dayLabel(dayStr) {
var parts = dayStr.split("-");
if (parts.length < 3) return dayStr;
var d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
return d.toLocaleDateString([], { day: "2-digit", month: "short" });
}
new Chart(canvas, {
type: "line",
data: {
labels: trend.map(function (row) { return dayLabel(row.day); }),
datasets: [
{
label: i18n.pageviews || "Pageviews",
data: trend.map(function (r) { return r.pageviews || 0; }),
borderColor: "rgba(229, 57, 53, 0.95)",
backgroundColor: "rgba(229, 57, 53, 0.18)",
fill: true,
tension: 0.25,
pointRadius: 3,
pointHoverRadius: 5
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
animation: { duration: 280 },
scales: {
x: {
ticks: { maxTicksLimit: 14, color: "#9a9aad", font: { size: 10 } },
grid: { color: "rgba(255,255,255,0.06)" }
},
y: {
beginAtZero: true,
ticks: {
color: "#9a9aad",
font: { size: 10 },
precision: 0
},
grid: { color: "rgba(255,255,255,0.06)" }
}
},
plugins: {
legend: { display: false }
}
}
});
})();
+38
View File
@@ -173,6 +173,44 @@ body.admin-body {
height: 200px; height: 200px;
} }
.chart-wrap--analytics {
height: 260px;
margin-top: 0.75rem;
}
.admin-analytics-trend__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.admin-analytics-trend__head h3 {
margin: 0 0 0.35rem;
}
.admin-analytics-trend__kpi {
margin: 0.75rem 0 0;
}
.admin-analytics-trend.is-collapsed .admin-analytics-trend__head {
margin-bottom: 0;
}
.admin-analytics-pages-title {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.admin-table tr.is-chart-focus td {
background: rgba(229, 57, 53, 0.12);
}
.admin-analytics-row-actions {
white-space: nowrap;
}
.admin-panels { .admin-panels {
display: grid; display: grid;
grid-template-columns: 1.4fr 1fr; grid-template-columns: 1.4fr 1fr;
+92 -3
View File
@@ -1183,6 +1183,93 @@ body.invite-editor--resizing {
.tournament-hub-hint { color: #888; margin: 0 0 12px; } .tournament-hub-hint { color: #888; margin: 0 0 12px; }
.tournament-table-wrap { overflow-x: auto; margin-top: 8px; } .tournament-table-wrap { overflow-x: auto; margin-top: 8px; }
.tournament-match-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
min-width: 0;
overflow-x: clip;
}
.tournament-match-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.9fr) minmax(0, 0.7fr);
gap: 10px 12px;
align-items: end;
min-width: 0;
padding: 14px;
border: 1px solid #2a2a36;
border-radius: 12px;
background: #14141c;
}
.tournament-match-row__field,
.tournament-match-row__side,
.tournament-match-row__score {
min-width: 0;
}
.tournament-match-row__field label,
.tournament-match-row__label {
display: block;
margin-bottom: 6px;
color: #9a9aa8;
font-size: 0.75rem;
letter-spacing: 0.02em;
}
.tournament-match-row__phase {
margin: 0;
padding: 8px 0;
color: #e8e8ee;
font-size: 0.95rem;
}
.tournament-match-row__sides {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(0, 1fr) 44px minmax(0, 1fr) auto;
gap: 8px 10px;
align-items: end;
min-width: 0;
}
.tournament-match-row__actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
.tournament-match-row .tournament-match-form__datetime,
.tournament-match-row .tournament-match-form__court,
.tournament-match-row .tournament-match-form__team {
width: 100%;
max-width: 100%;
min-width: 0;
margin-bottom: 0;
padding: 8px 10px;
font-size: 0.9rem;
box-sizing: border-box;
}
@media (max-width: 799px) {
.tournament-match-row {
grid-template-columns: 1fr 1fr;
}
.tournament-match-row__field--phase {
grid-column: 1 / -1;
}
.tournament-match-row__sides {
grid-template-columns: minmax(0, 1fr) 44px minmax(0, 1fr);
}
.tournament-match-row__score {
grid-column: 1 / -1;
}
}
@media (max-width: 560px) {
.tournament-match-row,
.tournament-match-row__sides {
grid-template-columns: minmax(0, 1fr);
}
.tournament-match-row__field--phase {
grid-column: auto;
}
.tournament-match-swap-col {
justify-self: center;
}
}
.tournament-index-actions { .tournament-index-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1205,19 +1292,19 @@ body.invite-editor--resizing {
background: #12121a; background: #12121a;
flex-shrink: 0; flex-shrink: 0;
} }
.tournament-match-table .tournament-match-form__datetime { table.tournament-match-table .tournament-match-form__datetime {
min-width: 190px; min-width: 190px;
margin-bottom: 0; margin-bottom: 0;
padding: 8px 10px; padding: 8px 10px;
font-size: 0.9rem; font-size: 0.9rem;
} }
.tournament-match-table .tournament-match-form__court { table.tournament-match-table .tournament-match-form__court {
min-width: 120px; min-width: 120px;
margin-bottom: 0; margin-bottom: 0;
padding: 8px 10px; padding: 8px 10px;
font-size: 0.9rem; font-size: 0.9rem;
} }
.tournament-match-table .tournament-match-form__team { table.tournament-match-table .tournament-match-form__team {
min-width: 160px; min-width: 160px;
max-width: 220px; max-width: 220px;
margin-bottom: 0; margin-bottom: 0;
@@ -1260,6 +1347,8 @@ body.invite-editor--resizing {
padding: 4px 10px; padding: 4px 10px;
font-size: 0.8rem; font-size: 0.8rem;
margin: 0; margin: 0;
flex-shrink: 0;
white-space: nowrap;
} }
.tournament-match-swap-col { .tournament-match-swap-col {
width: 44px; width: 44px;
+1 -1
View File
@@ -10,7 +10,7 @@
event.stopImmediatePropagation(); event.stopImmediatePropagation();
if (form.dataset.swapBusy === "1") return; if (form.dataset.swapBusy === "1") return;
var row = form.closest("tr"); var row = form.closest(".tournament-match-row") || form.closest("tr");
if (!row) return; if (!row) return;
form.dataset.swapBusy = "1"; form.dataset.swapBusy = "1";
+80 -2
View File
@@ -83,15 +83,93 @@ RSpec.describe Match, "scheduling scopes" do
expect(match.coach_hub_visible?).to be(false) expect(match.coach_hub_visible?).to be(false)
end end
it "nasconde partita programmata nel passato mai trasmessa" do it "nasconde partita con risultato inserito dal portale anche senza diretta" do
match = team.matches.create!( match = team.matches.create!(
opponent_name: "Missed", opponent_name: "Squadra 4",
scheduled_at: 2.hours.ago,
sets_to_win: 3,
home_score: 11,
away_score: 10,
result_status: "played"
)
expect(match.coach_hub_visible?).to be(false)
end
it "nasconde walkover anche se il torneo è ancora aperto" do
broadcast = club.teams.create!(
name: "Torneo Walkover",
sport_key: "pallavolo",
internal_kind: Tournament::INTERNAL_TEAM_KIND
)
tournament = Tournament.create!(
club: club,
name: "Torneo Walkover",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free",
status: "published",
broadcast_team: broadcast
)
match = broadcast.matches.create!(
opponent_name: "TBD",
scheduled_at: 1.hour.ago,
sets_to_win: 3,
tournament: tournament,
result_status: "walkover_home",
home_score: 1,
away_score: 0
)
expect(match.coach_hub_visible?).to be(false)
end
it "mostra partita di oggi già passata d'orario se non è stata trasmessa" do
match = team.matches.create!(
opponent_name: "Late",
scheduled_at: 2.hours.ago, scheduled_at: 2.hours.ago,
sets_to_win: 3 sets_to_win: 3
) )
expect(match.coach_hub_visible?).to be(true)
end
it "nasconde partita di un giorno precedente mai trasmessa" do
match = team.matches.create!(
opponent_name: "Yesterday",
scheduled_at: 1.day.ago.change(hour: 10),
sets_to_win: 3
)
expect(match.coach_hub_visible?).to be(false) expect(match.coach_hub_visible?).to be(false)
end end
it "mostra gara di torneo in corso anche se l'orario era ieri" do
broadcast = club.teams.create!(
name: "Torneo Late",
sport_key: "pallavolo",
internal_kind: Tournament::INTERNAL_TEAM_KIND
)
tournament = Tournament.create!(
club: club,
name: "Torneo Late",
sport_key: "pallavolo",
starts_on: Date.yesterday,
ends_on: Date.current,
format_kind: "free",
status: "published",
broadcast_team: broadcast
)
match = broadcast.matches.create!(
opponent_name: "TBD",
scheduled_at: 1.day.ago.change(hour: 10),
sets_to_win: 3,
tournament: tournament
)
expect(match.coach_hub_visible?).to be(true)
end
end end
describe "scoring_rules" do describe "scoring_rules" do
+41
View File
@@ -31,4 +31,45 @@ RSpec.describe Recording do
rec = described_class.create!(stream_session: session, team: team, status: "processing") rec = described_class.create!(stream_session: session, team: team, status: "processing")
expect(rec.title_or_default).to eq("Team vs Rival") expect(rec.title_or_default).to eq("Team vs Rival")
end end
it "usa le squadre del tabellone al posto della squadra di trasmissione" do
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
ClubMembership.create!(user: user, club: club, role: "owner")
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Memorial",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free"
}
)
home = tournament.participants.create!(name: "Gamma")
away = tournament.participants.create!(name: "Beta")
tournament_match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
scheduled_at: 1.hour.from_now
}
)
tournament_session = StreamSession.create!(
match: tournament_match,
user: user,
platform: "matchlivetv",
status: "ended",
privacy_status: "public"
)
rec = described_class.create!(
stream_session: tournament_session,
team: tournament.broadcast_team,
status: "ready",
title: "#{tournament.broadcast_team.name} vs Beta"
)
expect(rec.title_or_default).to eq("Gamma vs Beta")
expect(rec.default_title).to eq("Gamma vs Beta")
end
end end
@@ -7,6 +7,8 @@ RSpec.describe "Admin analytics", type: :request do
before do before do
post admin_login_path, params: { username: admin.username, password: "Password123" } post admin_login_path, params: { username: admin.username, password: "Password123" }
AnalyticsPageStat.delete_all
AnalyticsPageCell.delete_all
AnalyticsPageStat.create!( AnalyticsPageStat.create!(
day: Time.zone.today, day: Time.zone.today,
page_path: "/prezzi", page_path: "/prezzi",
@@ -33,8 +35,62 @@ RSpec.describe "Admin analytics", type: :request do
expect(response.body).to include("/prezzi") expect(response.body).to include("/prezzi")
expect(response.body).to include("12") expect(response.body).to include("12")
expect(response.body).to include("40") expect(response.body).to include("40")
expect(response.body).to include("chart-analytics-trend")
expect(response.body).to include("adminAnalyticsTrend")
expect(response.body).to include(I18n.t("admin.analytics.index.hide_chart"))
expect(response.body).to include("hideChart")
expect(response.body).not_to include("clicks:")
end end
it "il grafico rispetta i filtri data e aggrega pageview di tutte le pagine" do
AnalyticsPageStat.create!(
day: 1.day.ago.to_date,
page_path: "/prezzi",
device: "desktop",
pageview_count: 5,
scroll_samples: 0,
scroll_sum_pct: 0,
max_scroll_pct: 0
)
AnalyticsPageStat.create!(
day: Time.zone.today,
page_path: "/altro",
device: "desktop",
pageview_count: 7,
scroll_samples: 0,
scroll_sum_pct: 0,
max_scroll_pct: 0
)
get admin_analytics_path, params: { from: 2.days.ago.to_date, to: Time.zone.today }
expect(response).to have_http_status(:ok)
expect(response.body).to include(I18n.t("admin.analytics.index.trend_total_label"))
expect(response.body).to include(">24<")
expect(response.body).to include("\"pageviews\":5")
expect(response.body).to include("\"pageviews\":19")
end
it "filtra il grafico su una sola pagina" do
AnalyticsPageStat.create!(
day: Time.zone.today,
page_path: "/altro",
device: "desktop",
pageview_count: 7,
scroll_samples: 0,
scroll_sum_pct: 0,
max_scroll_pct: 0
)
get admin_analytics_path, params: { chart_path: "/prezzi" }
expect(response).to have_http_status(:ok)
expect(response.body).to include(I18n.t("admin.analytics.index.trend_total_label_path"))
expect(response.body).to include(">12<")
expect(response.body).to include(I18n.t("admin.analytics.index.chart_for_path"))
expect(response.body).to include("is-chart-focus")
expect(response.body).not_to include(">19<")
end
it "mostra la heatmap di una pagina con tab dispositivo" 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)
@@ -366,6 +366,7 @@ RSpec.describe "Tournaments", type: :request do
get public_tournament_path(tournament, tab: "calendario") get public_tournament_path(tournament, tab: "calendario")
expect(response.body).to include("tournament-match-swap__btn") expect(response.body).to include("tournament-match-swap__btn")
expect(response.body).to include("tournament-match-row")
expect(response.body).to include(public_swap_tournament_match_path(tournament, match)) expect(response.body).to include(public_swap_tournament_match_path(tournament, match))
expect(response.body).to include("tournament-calendar.js") expect(response.body).to include("tournament-calendar.js")
@@ -767,7 +768,7 @@ RSpec.describe "Tournaments", type: :request do
session.update_columns(status: "ended", ended_at: Time.current) session.update_columns(status: "ended", ended_at: Time.current)
Recording.create!( Recording.create!(
stream_session: session, team: tournament.broadcast_team, status: "ready", privacy_status: "public", stream_session: session, team: tournament.broadcast_team, status: "ready", privacy_status: "public",
title: "Replay Alfa-Beta", title: "#{tournament.broadcast_team.name} vs #{away.name}",
storage_key: "teams/#{tournament.broadcast_team_id}/sessions/#{session.id}/replay.mp4" storage_key: "teams/#{tournament.broadcast_team_id}/sessions/#{session.id}/replay.mp4"
) )
@@ -775,9 +776,89 @@ RSpec.describe "Tournaments", type: :request do
expect(response.body).to include("Guarda replay") expect(response.body).to include("Guarda replay")
expect(response.body).to include(public_replay_path(session.id)) expect(response.body).to include(public_replay_path(session.id))
expect(response.body).to include("tournament-live-status--replay") expect(response.body).to include("tournament-live-status--replay")
expect(response.body).to include("Replay Alfa-Beta") expect(response.body).to include("#{home.name} vs #{away.name}")
expect(response.body).not_to include("#{tournament.broadcast_team.name} vs #{away.name}")
get public_club_tournaments_path(club) get public_club_tournaments_path(club)
expect(response.body).to include(public_tournament_page_path(tournament.slug)) expect(response.body).to include(public_tournament_page_path(tournament.slug))
end end
it "marca In diretta una gara YouTube o in collegamento, non In attesa" do
assign_plan!("premium_full")
login!
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Open Diretta YouTube",
sport_key: "basket",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free",
courts: "Campo A"
}
)
home = tournament.participants.create!(name: "Squadra 1")
away = tournament.participants.create!(name: "Squadra 3")
match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
court: "Campo A",
scheduled_at: 30.minutes.ago
}
)
post public_publish_tournament_path(tournament)
session = StreamSession.create!(
match: match, user: coach, platform: "youtube", status: "connecting",
privacy_status: "unlisted", youtube_broadcast_id: "ytLiveBoard1"
)
get public_tournament_page_path(tournament.slug)
expect(response.body).to include("tournament-live-status--live")
expect(response.body).to include("In diretta")
expect(response.body).not_to include("tournament-live-status--waiting")
expect(response.body).to include("Guarda su YouTube")
expect(response.body).to include(session.youtube_watch_url)
get public_tournament_path(tournament, tab: "dirette")
expect(response.body).to include("tournament-live-status--live")
expect(response.body).to include("In diretta")
expect(response.body).not_to include("tournament-live-status--waiting_operator")
end
it "marca Terminata una gara con risultato anche se l'orario è ancora futuro" do
assign_plan!("premium_full")
login!
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Open Risultato Anticipato",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current + 1,
format_kind: "free",
courts: "Campo 1"
}
)
home = tournament.participants.create!(name: "Alfa")
away = tournament.participants.create!(name: "Delta")
match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
court: "Campo 1",
scheduled_at: 6.hours.from_now
}
)
Tournaments::RecordResult.call(match: match, home_score: 2, away_score: 0, source: "manual")
post public_publish_tournament_path(tournament)
get public_tournament_page_path(tournament.slug, tab: "risultati")
expect(response.body).to include("20")
expect(response.body).to include("tournament-live-status--ended")
expect(response.body).to include("Terminata")
expect(response.body).not_to include("tournament-live-status--scheduled")
end
end end
@@ -28,6 +28,7 @@ RSpec.describe Recordings::FinalizeSession do
expect(rec.storage_policy).to eq("retained") expect(rec.storage_policy).to eq("retained")
expect(rec.expires_at).to be > 29.days.from_now expect(rec.expires_at).to be > 29.days.from_now
expect(rec.metadata["auto_publish_youtube"]).to eq(false) expect(rec.metadata["auto_publish_youtube"]).to eq(false)
expect(rec.title).to eq("Team vs Rival")
end end
it "skips free plan" do it "skips free plan" do
+2 -2
View File
@@ -24,8 +24,8 @@ android {
applicationId = "com.matchlivetv.match_live_tv" applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 33 versionCode = 34
versionName = "2.0.12-native" versionName = "2.0.13-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String? val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
?: "https://www.matchlivetv.it" ?: "https://www.matchlivetv.it"
@@ -51,3 +51,11 @@ private fun parseInstantOrNull(value: String): Instant? {
} }
fun Instant.isScheduledFuture(now: Instant = Instant.now()): Boolean = isAfter(now) fun Instant.isScheduledFuture(now: Instant = Instant.now()): Boolean = isAfter(now)
fun Instant.isScheduledOnCalendar(
now: Instant = Instant.now(),
zone: ZoneId = ZoneId.of("Europe/Rome"),
): Boolean {
val startOfToday = now.atZone(zone).toLocalDate().atStartOfDay(zone).toInstant()
return !isBefore(startOfToday)
}
@@ -1,10 +1,10 @@
package com.matchlivetv.match_live_tv.domain package com.matchlivetv.match_live_tv.domain
import com.matchlivetv.match_live_tv.core.isScheduledFuture import com.matchlivetv.match_live_tv.core.isScheduledOnCalendar
import com.matchlivetv.match_live_tv.core.parseApiInstant import com.matchlivetv.match_live_tv.core.parseApiInstant
import java.time.Instant import java.time.Instant
/** Partite visibili nell'hub coach: dirette attive, wizard in corso, programmate future. */ /** Partite visibili nell'hub coach: dirette attive, wizard in corso, in calendario oggi o future. */
fun Match.isCoachHubVisible(now: Instant = Instant.now()): Boolean { fun Match.isCoachHubVisible(now: Instant = Instant.now()): Boolean {
coachHubVisible?.let { return it } coachHubVisible?.let { return it }
@@ -15,7 +15,7 @@ fun Match.isCoachHubVisible(now: Instant = Instant.now()): Boolean {
if (streamCompleted) return false if (streamCompleted) return false
val scheduled = parseApiInstant(scheduledAt) val scheduled = parseApiInstant(scheduledAt)
return scheduled != null && scheduled.isScheduledFuture(now) return scheduled != null && scheduled.isScheduledOnCalendar(now)
} }
fun List<Match>.coachHubVisible(now: Instant = Instant.now()): List<Match> = fun List<Match>.coachHubVisible(now: Instant = Instant.now()): List<Match> =
@@ -114,34 +114,38 @@ class CompactScoreboardElement : OverlayElement {
val accentColor = if (isHome) compact.homeAccentColor else compact.awayAccentColor val accentColor = if (isHome) compact.homeAccentColor else compact.awayAccentColor
val logoUrl = if (isHome) compact.homeLogoUrl else compact.awayLogoUrl val logoUrl = if (isHome) compact.homeLogoUrl else compact.awayLogoUrl
val logo = OverlayLogoCache.get(logoUrl) val logo = OverlayLogoCache.get(logoUrl)
val rawName = if (isHome) compact.homeTeamName else compact.awayTeamName val rawName = (if (isHome) compact.homeTeamName else compact.awayTeamName).trim()
val name = ellipsize(rawName.trim(), labelPaint, maxWidth * 0.55f) val barHalfH = logoSize * 0.45f
val textY = centerY + labelPaint.textSize * 0.35f
if (isHome) { if (isHome) {
var x = rectLeft var x = rectLeft
accentBarPaint.color = accentColor accentBarPaint.color = accentColor
canvas.drawRect(x, centerY - logoSize * 0.45f, x + barW, centerY + logoSize * 0.45f, accentBarPaint) canvas.drawRect(x, centerY - barHalfH, x + barW, centerY + barHalfH, accentBarPaint)
x += barW + gap x += barW + gap
if (logo != null) { if (logo != null) {
val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f) val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f)
canvas.drawBitmap(logo, null, dest, logoPaint) canvas.drawBitmap(logo, null, dest, logoPaint)
x += logoSize + gap x += logoSize + gap
} }
canvas.drawText(name, x, centerY + labelPaint.textSize * 0.35f, labelPaint) val name = ellipsize(rawName, labelPaint, (rectRight - x).coerceAtMost(maxWidth))
canvas.drawText(name, x, textY, labelPaint)
} else { } else {
// Specchio del lato casa: barra, logo, poi nome a sinistra del logo.
var x = rectRight var x = rectRight
accentBarPaint.color = accentColor accentBarPaint.color = accentColor
canvas.drawRect(x - barW, centerY - logoSize * 0.45f, x, centerY + logoSize * 0.45f, accentBarPaint) canvas.drawRect(x - barW, centerY - barHalfH, x, centerY + barHalfH, accentBarPaint)
x -= barW + gap x -= barW + gap
labelPaint.textAlign = Paint.Align.RIGHT
canvas.drawText(name, x, centerY + labelPaint.textSize * 0.35f, labelPaint)
labelPaint.textAlign = Paint.Align.LEFT
x -= gap
if (logo != null) { if (logo != null) {
x -= logoSize x -= logoSize
val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f) val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f)
canvas.drawBitmap(logo, null, dest, logoPaint) canvas.drawBitmap(logo, null, dest, logoPaint)
x -= gap
} }
val name = ellipsize(rawName, labelPaint, (x - rectLeft).coerceAtMost(maxWidth))
labelPaint.textAlign = Paint.Align.RIGHT
canvas.drawText(name, x, textY, labelPaint)
labelPaint.textAlign = Paint.Align.LEFT
} }
} }
@@ -47,8 +47,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.R import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.core.isScheduledFuture
import com.matchlivetv.match_live_tv.core.parseApiInstant
import com.matchlivetv.match_live_tv.data.AppContainer import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher
import com.matchlivetv.match_live_tv.domain.Match import com.matchlivetv.match_live_tv.domain.Match
@@ -161,7 +159,7 @@ fun MatchesScreen(
(match.hasActiveSession && match.activeSessionStatus == "idle") (match.hasActiveSession && match.activeSessionStatus == "idle")
} }
val scheduledMatches = matches.filter { match -> val scheduledMatches = matches.filter { match ->
!match.hasActiveSession && parseApiInstant(match.scheduledAt)?.isScheduledFuture() == true !match.hasActiveSession && !match.scheduledAt.isNullOrBlank()
} }
val draftMatches = matches.filter { !it.hasActiveSession && it.scheduledAt == null } val draftMatches = matches.filter { !it.hasActiveSession && it.scheduledAt == null }
val calendarMatches = (scheduledMatches + draftMatches).distinctBy { it.id } val calendarMatches = (scheduledMatches + draftMatches).distinctBy { it.id }
@@ -10,7 +10,22 @@ class ApiInstantTest {
fun parseApiInstant_railsOffsetWithMillis() { fun parseApiInstant_railsOffsetWithMillis() {
val instant = parseApiInstant("2026-06-06T20:00:00.000+02:00") val instant = parseApiInstant("2026-06-06T20:00:00.000+02:00")
assertNotNull(instant) assertNotNull(instant)
assertTrue(instant!!.isScheduledFuture(Instant.parse("2026-06-06T16:00:00Z"))) assertTrue(instant!!.isScheduledOnCalendar(Instant.parse("2026-06-06T16:00:00Z")))
}
@Test
fun isScheduledOnCalendar_keepsThisMorningAfterKickoff() {
val kickoff = Instant.parse("2026-09-06T07:00:00Z")
val now = Instant.parse("2026-09-06T08:30:00Z")
assertTrue(kickoff.isScheduledOnCalendar(now))
assertTrue(!kickoff.isScheduledFuture(now))
}
@Test
fun isScheduledOnCalendar_hidesYesterday() {
val yesterday = Instant.parse("2026-09-05T10:00:00Z")
val now = Instant.parse("2026-09-06T08:30:00Z")
assertTrue(!yesterday.isScheduledOnCalendar(now))
} }
@Test @Test
@@ -677,11 +677,11 @@
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 38;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
IPHONEOS_DEPLOYMENT_TARGET = 16.6; IPHONEOS_DEPLOYMENT_TARGET = 16.6;
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.tests"; PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.tests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -698,7 +698,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 38;
DEVELOPMENT_TEAM = S8Q9TWBRG5; DEVELOPMENT_TEAM = S8Q9TWBRG5;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
@@ -709,7 +709,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv"; PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -727,7 +727,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 38;
DEVELOPMENT_TEAM = S8Q9TWBRG5; DEVELOPMENT_TEAM = S8Q9TWBRG5;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
@@ -739,7 +739,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv"; PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -756,11 +756,11 @@
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 38;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
IPHONEOS_DEPLOYMENT_TARGET = 16.6; IPHONEOS_DEPLOYMENT_TARGET = 16.6;
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.tests"; PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.tests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -775,11 +775,11 @@
buildSettings = { buildSettings = {
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 38;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
IPHONEOS_DEPLOYMENT_TARGET = 16.6; IPHONEOS_DEPLOYMENT_TARGET = 16.6;
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.uitests"; PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.uitests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -794,11 +794,11 @@
buildSettings = { buildSettings = {
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 38;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
IPHONEOS_DEPLOYMENT_TARGET = 16.6; IPHONEOS_DEPLOYMENT_TARGET = 16.6;
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.uitests"; PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.uitests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -19,6 +19,14 @@ enum ApiInstant {
return date > now return date > now
} }
static func isScheduledOnCalendar(_ raw: String?, now: Date = Date()) -> Bool {
guard let date = parse(raw) else { return false }
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Europe/Rome") ?? .current
let startOfToday = calendar.startOfDay(for: now)
return date >= startOfToday
}
static func formatMatchDate(_ raw: String?) -> String? { static func formatMatchDate(_ raw: String?) -> String? {
guard let date = parse(raw) else { return nil } guard let date = parse(raw) else { return nil }
let f = DateFormatter() let f = DateFormatter()
@@ -12,13 +12,17 @@ enum MatchHubFilter {
} }
if match.streamCompleted { return false } if match.streamCompleted { return false }
return ApiInstant.isScheduledFuture(match.scheduledAt, now: now) return ApiInstant.isScheduledOnCalendar(match.scheduledAt, now: now)
} }
static func isScheduledFuture(_ match: Match, now: Date = Date()) -> Bool { static func isScheduledFuture(_ match: Match, now: Date = Date()) -> Bool {
ApiInstant.isScheduledFuture(match.scheduledAt, now: now) ApiInstant.isScheduledFuture(match.scheduledAt, now: now)
} }
static func isOnCalendar(_ match: Match, now: Date = Date()) -> Bool {
ApiInstant.isScheduledOnCalendar(match.scheduledAt, now: now)
}
static func isDraft(_ match: Match) -> Bool { static func isDraft(_ match: Match) -> Bool {
!match.hasActiveSession && (match.scheduledAt == nil || match.scheduledAt?.isEmpty == true) !match.hasActiveSession && (match.scheduledAt == nil || match.scheduledAt?.isEmpty == true)
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 215 KiB

+2 -2
View File
@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2.0.12</string> <string>2.0.13</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
@@ -32,7 +32,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>35</string> <string>38</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
@@ -67,32 +67,37 @@ struct CompactScoreboardElement: OverlayElement {
let accent = isHome ? compact.homeAccentColor : compact.awayAccentColor let accent = isHome ? compact.homeAccentColor : compact.awayAccentColor
let logoUrl = isHome ? compact.homeLogoUrl : compact.awayLogoUrl let logoUrl = isHome ? compact.homeLogoUrl : compact.awayLogoUrl
let logo = OverlayLogoCache.get(logoUrl) let logo = OverlayLogoCache.get(logoUrl)
let rawName = isHome ? compact.homeTeamName : compact.awayTeamName let rawName = (isHome ? compact.homeTeamName : compact.awayTeamName)
let name = ellipsize(rawName.trimmingCharacters(in: .whitespacesAndNewlines), font: labelFont, maxWidth: maxWidth * 0.55) .trimmingCharacters(in: .whitespacesAndNewlines)
let attrs: [NSAttributedString.Key: Any] = [.font: labelFont, .foregroundColor: UIColor.white] let attrs: [NSAttributedString.Key: Any] = [.font: labelFont, .foregroundColor: UIColor.white]
let textY = centerY + labelFont.pointSize * 0.35 - labelFont.lineHeight
let barHalfH = CGFloat(logoSize) * 0.45
if isHome { if isHome {
var x = rectLeft var x = rectLeft
context.setFillColor(accent.cgColor) context.setFillColor(accent.cgColor)
context.fill(CGRect(x: x, y: centerY - CGFloat(logoSize) * 0.45, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9)) context.fill(CGRect(x: x, y: centerY - barHalfH, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9))
x += CGFloat(barW + gap) x += CGFloat(barW + gap)
if let logo, let cg = logo.cgImage { if let logo, let cg = logo.cgImage {
context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize))) context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize)))
x += CGFloat(logoSize + gap) x += CGFloat(logoSize + gap)
} }
(name as NSString).draw(at: CGPoint(x: x, y: centerY + labelFont.pointSize * 0.35 - labelFont.lineHeight), withAttributes: attrs) let name = ellipsize(rawName, font: labelFont, maxWidth: min(rectRight - x, maxWidth))
(name as NSString).draw(at: CGPoint(x: x, y: textY), withAttributes: attrs)
} else { } else {
// Specchio del lato casa: barra, logo, poi nome a sinistra del logo.
var x = rectRight var x = rectRight
context.setFillColor(accent.cgColor) context.setFillColor(accent.cgColor)
context.fill(CGRect(x: x - CGFloat(barW), y: centerY - CGFloat(logoSize) * 0.45, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9)) context.fill(CGRect(x: x - CGFloat(barW), y: centerY - barHalfH, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9))
x -= CGFloat(barW + gap) x -= CGFloat(barW + gap)
let nameSize = (name as NSString).size(withAttributes: attrs)
(name as NSString).draw(at: CGPoint(x: x - nameSize.width, y: centerY + labelFont.pointSize * 0.35 - labelFont.lineHeight), withAttributes: attrs)
x -= CGFloat(gap)
if let logo, let cg = logo.cgImage { if let logo, let cg = logo.cgImage {
x -= CGFloat(logoSize) x -= CGFloat(logoSize)
context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize))) context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize)))
x -= CGFloat(gap)
} }
let name = ellipsize(rawName, font: labelFont, maxWidth: min(x - rectLeft, maxWidth))
let nameSize = (name as NSString).size(withAttributes: attrs)
(name as NSString).draw(at: CGPoint(x: x - nameSize.width, y: textY), withAttributes: attrs)
} }
} }
@@ -289,7 +289,7 @@ struct MatchesScreen: View {
} }
private var scheduledMatches: [Match] { private var scheduledMatches: [Match] {
matches.filter { !$0.hasActiveSession && MatchHubFilter.isScheduledFuture($0) } matches.filter { !$0.hasActiveSession && !($0.scheduledAt ?? "").isEmpty }
} }
private var calendarMatches: [Match] { private var calendarMatches: [Match] {
@@ -1,7 +1,45 @@
import XCTest import XCTest
@testable import MatchLiveTv
/// Smoke test eseguibile senza @testable import (validazione logica pura). /// Logica calendario/API reale + smoke parser (RTMP/overlay) senza dipendenze di rete.
final class ApiInstantTests: XCTestCase { final class ApiInstantTests: XCTestCase {
func testParseApiInstantRailsOffsetWithMillis() {
let now = iso("2026-06-06T16:00:00Z")
XCTAssertTrue(ApiInstant.isScheduledOnCalendar("2026-06-06T20:00:00.000+02:00", now: now))
}
func testIsScheduledOnCalendarKeepsThisMorningAfterKickoff() {
let kickoff = "2026-09-06T07:00:00Z"
let now = iso("2026-09-06T08:30:00Z")
XCTAssertTrue(ApiInstant.isScheduledOnCalendar(kickoff, now: now))
XCTAssertFalse(ApiInstant.isScheduledFuture(kickoff, now: now))
}
func testIsScheduledOnCalendarHidesYesterday() {
let yesterday = "2026-09-05T10:00:00Z"
let now = iso("2026-09-06T08:30:00Z")
XCTAssertFalse(ApiInstant.isScheduledOnCalendar(yesterday, now: now))
}
func testConcurrentStreamForbiddenShowsApiMessage() {
let body = """
{"error":"Hai già una diretta in corso con questo account. Chiudila prima di avviarne unaltra.","error_code":"user_concurrent_stream"}
"""
XCTAssertEqual(
APIError.http(403, body).errorDescription,
"Hai già una diretta in corso con questo account. Chiudila prima di avviarne unaltra."
)
}
private func iso(_ value: String) -> Date {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
guard let date = f.date(from: value) else {
XCTFail("Data ISO non valida: \(value)")
return Date()
}
return date
}
func testOverlayKindMapping() { func testOverlayKindMapping() {
XCTAssertEqual(OverlayKindMapping.fromApi("basket"), "basket") XCTAssertEqual(OverlayKindMapping.fromApi("basket"), "basket")
XCTAssertEqual(OverlayKindMapping.fromApi("none"), "none") XCTAssertEqual(OverlayKindMapping.fromApi("none"), "none")
@@ -121,6 +121,65 @@ final class MatchScoringRulesTests: XCTestCase {
} }
} }
final class MatchHubCalendarTests: XCTestCase {
func testHubKeepsMatchAfterKickoffSameDay() {
let now = iso("2026-09-06T08:30:00Z")
let match = hubMatch(scheduledAt: "2026-09-06T07:00:00Z")
XCTAssertTrue(MatchHubFilter.coachHubVisible(match, now: now))
XCTAssertTrue(MatchHubFilter.isOnCalendar(match, now: now))
XCTAssertFalse(MatchHubFilter.isScheduledFuture(match, now: now))
}
func testHubHidesYesterdayMatch() {
let now = iso("2026-09-06T08:30:00Z")
let match = hubMatch(scheduledAt: "2026-09-05T10:00:00Z")
XCTAssertFalse(MatchHubFilter.coachHubVisible(match, now: now))
}
func testHubRespectsApiCoachHubVisibleFalse() {
let now = iso("2026-09-06T08:30:00Z")
let match = hubMatch(scheduledAt: "2026-09-06T07:00:00Z", coachHubVisible: false)
XCTAssertFalse(MatchHubFilter.coachHubVisible(match, now: now))
}
private func iso(_ value: String) -> Date {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f.date(from: value)!
}
private func hubMatch(scheduledAt: String?, coachHubVisible: Bool? = nil) -> Match {
Match(
id: "match-hub",
teamId: "team-1",
teamName: "Team MLTV",
opponentName: "Team Guest",
location: nil,
scheduledAt: scheduledAt,
sportKey: "pallavolo",
sportLabel: "Pallavolo",
boardType: "volley",
overlayKind: "volley",
effectiveOverlayKind: "volley",
setsToWin: 3,
category: nil,
scoringRules: nil,
activeSessionId: nil,
activeSessionStatus: nil,
streamCompleted: false,
coachHubVisible: coachHubVisible,
homePrimaryColor: "#FF2D2D",
homeSecondaryColor: nil,
homeLogoUrl: nil,
opponentPrimaryColor: "#1E3A8A",
opponentLogoUrl: nil,
effectiveCoverUrl: nil,
coverSource: "default",
customCoverEnabled: false
)
}
}
final class ScoreboardOverlayTests: XCTestCase { final class ScoreboardOverlayTests: XCTestCase {
func testScoreboardColumnsAfterSetClosed() { func testScoreboardColumnsAfterSetClosed() {
let score = ScoreState( let score = ScoreState(
+4 -4
View File
@@ -96,7 +96,7 @@ lines += [
app_settings = """ app_settings = """
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 38;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist; INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
@@ -107,7 +107,7 @@ app_settings = """
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv; PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
API_BASE_URL = "https://www.matchlivetv.it"; API_BASE_URL = "https://www.matchlivetv.it";
@@ -122,10 +122,10 @@ app_debug_settings = app_settings + """
test_settings = """ test_settings = """
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 38;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests; PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;