Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53ad26fdbe | ||
|
|
db0e842ca7 | ||
|
|
2e67e590d6 | ||
|
|
bb342ead79 | ||
|
|
ce81bb5789 | ||
|
|
24c6ce4da0 | ||
|
|
c3ef8e91a6 | ||
|
|
b09e83db09 | ||
|
|
097e55df79 | ||
|
|
7c7b2bf14c |
@@ -36,6 +36,23 @@ 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]] }
|
||||||
|
|
||||||
|
pageviews_by_day = scope.group(:day).sum(:pageview_count)
|
||||||
|
clicks_by_day = cell_scope.group(:day).sum(:click_count)
|
||||||
|
moves_by_day = cell_scope.group(:day).sum(:move_count)
|
||||||
|
@trend = (@filters[:from]..@filters[:to]).map do |day|
|
||||||
|
{
|
||||||
|
day: day.iso8601,
|
||||||
|
pageviews: pageviews_by_day[day].to_i,
|
||||||
|
clicks: clicks_by_day[day].to_i,
|
||||||
|
moves: moves_by_day[day].to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
@trend_totals = {
|
||||||
|
pageviews: @trend.sum { |r| r[:pageviews] },
|
||||||
|
clicks: @trend.sum { |r| r[:clicks] },
|
||||||
|
moves: @trend.sum { |r| r[:moves] }
|
||||||
|
}
|
||||||
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -58,6 +58,40 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if @trend.any? && (@trend_totals[:pageviews].positive? || @trend_totals[:clicks].positive? || @trend_totals[:moves].positive?) %>
|
||||||
|
<section class="panel admin-analytics-trend">
|
||||||
|
<h3><%= t("admin.analytics.index.trend_title") %></h3>
|
||||||
|
<p class="muted admin-table-sub"><%= t("admin.analytics.index.trend_lead") %></p>
|
||||||
|
<div class="kpi-grid admin-analytics-trend__kpi">
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label"><%= t("admin.analytics.index.table.pageviews") %></div>
|
||||||
|
<div class="kpi-value"><%= @trend_totals[:pageviews] %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label"><%= t("admin.analytics.index.table.clicks") %></div>
|
||||||
|
<div class="kpi-value"><%= @trend_totals[:clicks] %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label"><%= t("admin.analytics.index.table.moves") %></div>
|
||||||
|
<div class="kpi-value"><%= @trend_totals[:moves] %></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>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
window.adminAnalyticsTrend = <%= raw @trend.to_json %>;
|
||||||
|
window.adminAnalyticsI18n = {
|
||||||
|
pageviews: <%= raw t("admin.analytics.index.table.pageviews").to_json %>,
|
||||||
|
clicks: <%= raw t("admin.analytics.index.table.clicks").to_json %>,
|
||||||
|
moves: <%= raw t("admin.analytics.index.table.moves").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=1" defer></script>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<% if @pages.any? %>
|
<% if @pages.any? %>
|
||||||
<div class="admin-table-wrap">
|
<div class="admin-table-wrap">
|
||||||
|
|||||||
@@ -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=17">
|
||||||
<%= 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">
|
||||||
|
|||||||
@@ -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,40 +8,35 @@
|
|||||||
|
|
||||||
<% 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") %>">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th><%= t("tournaments.hub.datetime") %></th>
|
|
||||||
<th><%= t("tournaments.hub.court") %></th>
|
|
||||||
<th><%= t("tournaments.hub.round") %></th>
|
|
||||||
<th><%= t("tournaments.hub.home") %></th>
|
|
||||||
<th class="tournament-match-swap-col"></th>
|
|
||||||
<th><%= t("tournaments.hub.away") %></th>
|
|
||||||
<th><%= t("tournaments.hub.result") %></th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<% @matches.each do |match| %>
|
<% @matches.each do |match| %>
|
||||||
<% form_id = "match-edit-#{match.id}" %>
|
<% form_id = "match-edit-#{match.id}" %>
|
||||||
<% courts = (@tournament.court_list + [match.court]).compact_blank.uniq %>
|
<% courts = (@tournament.court_list + [match.court]).compact_blank.uniq %>
|
||||||
<%= form_with url: public_tournament_match_path(@tournament, match), method: :patch, html: { id: form_id, class: "visually-hidden" } do %>
|
<%= form_with url: public_tournament_match_path(@tournament, match), method: :patch, html: { id: form_id, class: "visually-hidden" } do %>
|
||||||
<%= hidden_field_tag :tab, @tab, form: form_id %>
|
<%= hidden_field_tag :tab, @tab, form: form_id %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<tr>
|
<article class="tournament-match-row">
|
||||||
<% if @writable %>
|
<% if @writable %>
|
||||||
<td>
|
<div class="tournament-match-row__field">
|
||||||
|
<label for="match_at_<%= match.id %>"><%= t("tournaments.hub.datetime") %></label>
|
||||||
<%= datetime_local_field_tag "match[scheduled_at]", tournament_datetime_local(match.scheduled_at),
|
<%= datetime_local_field_tag "match[scheduled_at]", tournament_datetime_local(match.scheduled_at),
|
||||||
id: "match_at_#{match.id}", form: form_id, required: true, class: "tournament-match-form__datetime" %>
|
id: "match_at_#{match.id}", form: form_id, required: true, class: "tournament-match-form__datetime" %>
|
||||||
</td>
|
</div>
|
||||||
<td>
|
<div class="tournament-match-row__field">
|
||||||
|
<label for="match_court_<%= match.id %>"><%= t("tournaments.hub.court") %></label>
|
||||||
<%= select_tag "match[court]", options_for_select(courts, match.court),
|
<%= select_tag "match[court]", options_for_select(courts, match.court),
|
||||||
id: "match_court_#{match.id}", form: form_id, include_blank: true, class: "tournament-match-form__court" %>
|
id: "match_court_#{match.id}", form: form_id, include_blank: true, class: "tournament-match-form__court" %>
|
||||||
</td>
|
</div>
|
||||||
<td><%= tournament_phase_label(match) %></td>
|
<div class="tournament-match-row__field tournament-match-row__field--phase">
|
||||||
<td data-swap-side="home"><%= render "public/tournaments/match_side_select", match: match, side: :home, form_id: form_id %></td>
|
<span class="tournament-match-row__label"><%= t("tournaments.hub.round") %></span>
|
||||||
<td class="tournament-match-swap-col">
|
<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>
|
||||||
|
<div data-swap-side="home"><%= render "public/tournaments/match_side_select", match: match, side: :home, form_id: form_id %></div>
|
||||||
|
</div>
|
||||||
|
<div class="tournament-match-swap-col">
|
||||||
<%= button_to public_swap_tournament_match_path(@tournament, match), method: :post,
|
<%= button_to public_swap_tournament_match_path(@tournament, match), method: :post,
|
||||||
class: "tournament-match-swap__btn",
|
class: "tournament-match-swap__btn",
|
||||||
title: t("tournaments.hub.swap_sides"),
|
title: t("tournaments.hub.swap_sides"),
|
||||||
@@ -49,11 +44,15 @@
|
|||||||
<i class="fa-solid fa-right-left" aria-hidden="true"></i>
|
<i class="fa-solid fa-right-left" aria-hidden="true"></i>
|
||||||
<span class="visually-hidden"><%= t("tournaments.hub.swap_sides") %></span>
|
<span class="visually-hidden"><%= t("tournaments.hub.swap_sides") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</div>
|
||||||
<td data-swap-side="away"><%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %></td>
|
<div class="tournament-match-row__side">
|
||||||
<td>
|
<span class="tournament-match-row__label"><%= t("tournaments.hub.away") %></span>
|
||||||
<% if match.played? || match.result_status.to_s.start_with?("walkover") %>
|
<div data-swap-side="away"><%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %></div>
|
||||||
<span data-swap-played-score><%= match.home_score %>–<%= match.away_score %></span>
|
</div>
|
||||||
|
<div class="tournament-match-row__score">
|
||||||
|
<span class="tournament-match-row__label"><%= t("tournaments.hub.result") %></span>
|
||||||
|
<% if match.result_recorded? %>
|
||||||
|
<p data-swap-played-score><%= match.home_score %>–<%= match.away_score %></p>
|
||||||
<% 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" } %>
|
||||||
@@ -61,35 +60,53 @@
|
|||||||
<%= 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 %>
|
||||||
</td>
|
</div>
|
||||||
<td class="tournament-match-form__actions">
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</tr>
|
</article>
|
||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -427,6 +427,8 @@ 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
|
||||||
|
trend_title: Verlauf über die Zeit
|
||||||
|
trend_lead: Tägliche Pageviews, Klicks und Bewegungen im gefilterten Zeitraum.
|
||||||
table:
|
table:
|
||||||
path: Seite
|
path: Seite
|
||||||
pageviews: Pageviews
|
pageviews: Pageviews
|
||||||
|
|||||||
@@ -427,6 +427,8 @@ 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
|
||||||
|
trend_title: Trend over time
|
||||||
|
trend_lead: Daily pageviews, clicks and moves for the filtered period.
|
||||||
table:
|
table:
|
||||||
path: Page
|
path: Page
|
||||||
pageviews: Pageviews
|
pageviews: Pageviews
|
||||||
|
|||||||
@@ -427,6 +427,8 @@ 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
|
||||||
|
trend_title: Evolución en el tiempo
|
||||||
|
trend_lead: Pageviews, clics y movimientos día a día en el periodo filtrado.
|
||||||
table:
|
table:
|
||||||
path: Página
|
path: Página
|
||||||
pageviews: Pageviews
|
pageviews: Pageviews
|
||||||
|
|||||||
@@ -427,6 +427,8 @@ 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
|
||||||
|
trend_title: Évolution dans le temps
|
||||||
|
trend_lead: Pages vues, clics et mouvements par jour sur la période filtrée.
|
||||||
table:
|
table:
|
||||||
path: Page
|
path: Page
|
||||||
pageviews: Pages vues
|
pageviews: Pages vues
|
||||||
|
|||||||
@@ -448,6 +448,8 @@ 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
|
||||||
|
trend_title: Andamento nel tempo
|
||||||
|
trend_lead: Pageview, click e movimenti giorno per giorno nel periodo filtrato.
|
||||||
table:
|
table:
|
||||||
path: Pagina
|
path: Pagina
|
||||||
pageviews: Pageview
|
pageviews: Pageview
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
(function () {
|
||||||
|
var trend = window.adminAnalyticsTrend || [];
|
||||||
|
var i18n = window.adminAnalyticsI18n || {};
|
||||||
|
var canvas = document.getElementById("chart-analytics-trend");
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var labels = trend.map(function (row) {
|
||||||
|
return dayLabel(row.day);
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(canvas, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.clicks || "Clicks",
|
||||||
|
data: trend.map(function (r) { return r.clicks || 0; }),
|
||||||
|
borderColor: "rgba(66, 165, 245, 0.95)",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
fill: false,
|
||||||
|
tension: 0.25,
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.moves || "Moves",
|
||||||
|
data: trend.map(function (r) { return r.moves || 0; }),
|
||||||
|
borderColor: "rgba(255, 193, 7, 0.85)",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
fill: false,
|
||||||
|
tension: 0.25,
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
borderDash: [4, 3]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
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: { labels: { color: "#ccc", boxWidth: 12 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -173,6 +173,15 @@ body.admin-body {
|
|||||||
height: 200px;
|
height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-wrap--analytics {
|
||||||
|
height: 260px;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-analytics-trend__kpi {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-panels {
|
.admin-panels {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.4fr 1fr;
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -33,6 +33,25 @@ 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")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra il trend anche su più giorni" 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
|
||||||
|
)
|
||||||
|
|
||||||
|
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("\"pageviews\":5")
|
||||||
|
expect(response.body).to include("\"pageviews\":12")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "mostra la heatmap di una pagina con tab dispositivo" do
|
it "mostra la heatmap di una pagina con tab dispositivo" do
|
||||||
|
|||||||
@@ -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("2–0")
|
||||||
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
+3
-3
@@ -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> =
|
||||||
|
|||||||
+13
-9
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -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 }
|
||||||
|
|||||||
+16
-1
@@ -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 |
@@ -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 un’altra.","error_code":"user_concurrent_stream"}
|
||||||
|
"""
|
||||||
|
XCTAssertEqual(
|
||||||
|
APIError.http(403, body).errorDescription,
|
||||||
|
"Hai già una diretta in corso con questo account. Chiudila prima di avviarne un’altra."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user