Compare commits

..
Author SHA1 Message Date
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
29 changed files with 545 additions and 132 deletions
@@ -10,7 +10,7 @@ module Api
def index
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)
unless current_user.club_admin?(@team.club)
if @team.tournament_broadcast?
@@ -16,7 +16,7 @@ module Public
Tournaments::Entitlements.new(@club).assert_writable!
match = @tournament.matches.find(params[:match_id])
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)
end
match.update!(attrs)
@@ -38,19 +38,21 @@ module Public
.includes(
:tournament_group,
:tournament_round,
:stream_sessions,
home_participant: { logo_file_attachment: :blob },
away_participant: { logo_file_attachment: :blob }
)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :asc)
@live_sessions = StreamSession
broadcasting = StreamSession
.broadcasting
.publicly_listed
.where(platform: "matchlivetv")
.includes(:score_state, match: [:home_participant, :away_participant, { team: :club }])
.where(match_id: @tournament.matches.select(:id))
.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
.joins(stream_session: :match)
@@ -67,7 +67,7 @@ module Public
sessions = match.stream_sessions.to_a
latest = sessions.max_by(&:created_at)
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
end
@@ -100,12 +100,24 @@ module Public
def tournament_public_board_state(match)
return :live if tournament_public_live_session(match)
return :replay if tournament_public_recording(match)
return :ended if match.result_recorded?
return :scheduled if match.scheduled_upcoming?
return :ended if match.played? || match.home_score.present?
:waiting
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)
return "#{match.home_score}#{match.away_score}" if match.home_score.present? && match.away_score.present?
@@ -117,7 +129,7 @@ module Public
end
def tournament_match_sides_locked?(match)
match.played? || match.result_status.to_s.start_with?("walkover")
match.result_recorded?
end
def tournament_phase_label(match)
+15 -2
View File
@@ -94,14 +94,27 @@ class Match < ApplicationRecord
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
end
def result_recorded?
played? || result_status.to_s.start_with?("walkover") || (home_score.present? && away_score.present?)
end
def coach_hub_visible?
active = active_stream_session
return true if active&.resumable?
return true if active&.idle?
return false if result_recorded?
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
def effective_board_type
+22 -2
View File
@@ -146,14 +146,22 @@ class Recording < ApplicationRecord
end
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
def default_title
match = stream_session&.match
return "Replay" unless match
"#{match.team.name} vs #{match.opponent_name}"
match.matchup_label
end
def recorded_at_or_fallback
@@ -222,6 +230,18 @@ class Recording < ApplicationRecord
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
self.privacy_status = "public" if privacy_status == "private"
self.privacy_status = "unlisted" if privacy_status.blank?
+6
View File
@@ -186,6 +186,12 @@ class StreamSession < ApplicationRecord
platform == "youtube" && youtube_watch_url.present?
end
def public_watchable?
return true if matchlivetv_platform? && publicly_listed?
youtube_ready?
end
def link_only?
privacy_status.in?(%w[unlisted private])
end
@@ -73,7 +73,7 @@ module Recordings
def default_title
match = @session.match
"#{match.team.name} vs #{match.opponent_name}"
match.matchup_label.presence || "Replay"
end
def privacy_from_session
+2 -2
View File
@@ -10,7 +10,7 @@
<%= render "shared/analytics_suppress" %>
<%= 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="/marketing.css?v=101">
<link rel="stylesheet" href="/marketing.css?v=102">
</head>
<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" %>
@@ -24,7 +24,7 @@
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
<script src="/branding-form.js?v=1" 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="/password-toggle.js?v=2" defer></script>
<link rel="stylesheet" href="/confirm-forms.css?v=4">
@@ -8,7 +8,7 @@
<%= render "shared/meta_tags" %>
<%= render "shared/analytics_suppress" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=101">
<link rel="stylesheet" href="/marketing.css?v=102">
<link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %>
</head>
@@ -37,8 +37,12 @@
</span>
</td>
<td class="tournament-board__actions">
<% if live_session %>
<%= link_to t("tournaments.page.watch_live"), public_live_path(live_session), class: "tournament-board__cta tournament-board__cta--live" %>
<% watch = tournament_public_watch_target(live_session) %>
<% 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 %>
<%= link_to t("tournaments.page.watch_replay"), public_replay_path(recording.stream_session_id), class: "tournament-board__cta" %>
<% else %>
@@ -52,8 +52,11 @@
<span class="replay-card__play" aria-hidden="true"></span>
</div>
<div class="replay-card__body">
<strong class="replay-card__title"><%= rec.title.presence || match.matchup_label %></strong>
<p class="replay-card__meta"><%= match.matchup_label %></p>
<% phase = [match.tournament_round&.name, match.tournament_group&.name].compact.first %>
<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>
<% end %>
<% end %>
@@ -64,7 +64,11 @@
<span class="badge badge-live"><%= t("live.index.badge_live") %></span>
<% end %>
</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>
<% end %>
</div>
@@ -8,40 +8,35 @@
<% if @matches.any? %>
<p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.calendar_edit_hint") %></p>
<div class="tournament-table-wrap">
<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>
<div class="tournament-match-list tournament-match-table" data-swap-error="<%= t("flash.tournaments.sides_swap_failed") %>">
<% @matches.each do |match| %>
<% form_id = "match-edit-#{match.id}" %>
<% 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 %>
<%= hidden_field_tag :tab, @tab, form: form_id %>
<% end %>
<tr>
<article class="tournament-match-row">
<% 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),
id: "match_at_#{match.id}", form: form_id, required: true, class: "tournament-match-form__datetime" %>
</td>
<td>
</div>
<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),
id: "match_court_#{match.id}", form: form_id, include_blank: true, class: "tournament-match-form__court" %>
</td>
<td><%= tournament_phase_label(match) %></td>
<td data-swap-side="home"><%= render "public/tournaments/match_side_select", match: match, side: :home, form_id: form_id %></td>
<td class="tournament-match-swap-col">
</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>
<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,
class: "tournament-match-swap__btn",
title: t("tournaments.hub.swap_sides"),
@@ -49,11 +44,15 @@
<i class="fa-solid fa-right-left" aria-hidden="true"></i>
<span class="visually-hidden"><%= t("tournaments.hub.swap_sides") %></span>
<% end %>
</td>
<td data-swap-side="away"><%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %></td>
<td>
<% if match.played? || match.result_status.to_s.start_with?("walkover") %>
<span data-swap-played-score><%= match.home_score %><%= match.away_score %></span>
</div>
<div class="tournament-match-row__side">
<span class="tournament-match-row__label"><%= t("tournaments.hub.away") %></span>
<div data-swap-side="away"><%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %></div>
</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 %>
<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" } %>
@@ -61,35 +60,53 @@
<%= number_field_tag :away_score, match.away_score, id: "away_score_#{match.id}", form: form_id, data: { swap_score: "away" } %>
</span>
<% end %>
</td>
<td class="tournament-match-form__actions">
</div>
</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" %>
<% if match.deletable? %>
<%= button_to t("matches.index.delete"), public_tournament_match_path(@tournament, match),
method: :delete, class: "btn btn-secondary tournament-match-form__save",
form: { class: "tournament-match-delete-form", data: { turbo_confirm: match.matchup_label } } %>
<% end %>
</td>
</div>
<% else %>
<td><%= match.scheduled_at ? l(match.scheduled_at, format: :short) : "—" %></td>
<td><%= match.court || "—" %></td>
<td><%= tournament_phase_label(match) %></td>
<td><%= tournament_team_chip(match.home_participant, name: match.home_display_name) %></td>
<td class="tournament-match-swap-col"></td>
<td><%= tournament_team_chip(match.away_participant, name: match.away_display_name) %></td>
<td>
<div class="tournament-match-row__field">
<span class="tournament-match-row__label"><%= t("tournaments.hub.datetime") %></span>
<p class="tournament-match-row__phase"><%= match.scheduled_at ? l(match.scheduled_at, format: :short) : "—" %></p>
</div>
<div class="tournament-match-row__field">
<span class="tournament-match-row__label"><%= t("tournaments.hub.court") %></span>
<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? %>
<%= match.home_score %><%= match.away_score %>
<% else %>
<% end %>
</td>
<td></td>
</p>
</div>
</div>
<% end %>
</tr>
</article>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.no_matches") %></p>
+92 -3
View File
@@ -1183,6 +1183,93 @@ body.invite-editor--resizing {
.tournament-hub-hint { color: #888; margin: 0 0 12px; }
.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 {
display: flex;
flex-wrap: wrap;
@@ -1205,19 +1292,19 @@ body.invite-editor--resizing {
background: #12121a;
flex-shrink: 0;
}
.tournament-match-table .tournament-match-form__datetime {
table.tournament-match-table .tournament-match-form__datetime {
min-width: 190px;
margin-bottom: 0;
padding: 8px 10px;
font-size: 0.9rem;
}
.tournament-match-table .tournament-match-form__court {
table.tournament-match-table .tournament-match-form__court {
min-width: 120px;
margin-bottom: 0;
padding: 8px 10px;
font-size: 0.9rem;
}
.tournament-match-table .tournament-match-form__team {
table.tournament-match-table .tournament-match-form__team {
min-width: 160px;
max-width: 220px;
margin-bottom: 0;
@@ -1260,6 +1347,8 @@ body.invite-editor--resizing {
padding: 4px 10px;
font-size: 0.8rem;
margin: 0;
flex-shrink: 0;
white-space: nowrap;
}
.tournament-match-swap-col {
width: 44px;
+1 -1
View File
@@ -10,7 +10,7 @@
event.stopImmediatePropagation();
if (form.dataset.swapBusy === "1") return;
var row = form.closest("tr");
var row = form.closest(".tournament-match-row") || form.closest("tr");
if (!row) return;
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)
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!(
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,
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)
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
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")
expect(rec.title_or_default).to eq("Team vs Rival")
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
@@ -366,6 +366,7 @@ RSpec.describe "Tournaments", type: :request do
get public_tournament_path(tournament, tab: "calendario")
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("tournament-calendar.js")
@@ -767,7 +768,7 @@ RSpec.describe "Tournaments", type: :request do
session.update_columns(status: "ended", ended_at: Time.current)
Recording.create!(
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"
)
@@ -775,9 +776,89 @@ RSpec.describe "Tournaments", type: :request do
expect(response.body).to include("Guarda replay")
expect(response.body).to include(public_replay_path(session.id))
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)
expect(response.body).to include(public_tournament_page_path(tournament.slug))
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
@@ -28,6 +28,7 @@ RSpec.describe Recordings::FinalizeSession do
expect(rec.storage_policy).to eq("retained")
expect(rec.expires_at).to be > 29.days.from_now
expect(rec.metadata["auto_publish_youtube"]).to eq(false)
expect(rec.title).to eq("Team vs Rival")
end
it "skips free plan" do
@@ -51,3 +51,11 @@ private fun parseInstantOrNull(value: String): Instant? {
}
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
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 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 {
coachHubVisible?.let { return it }
@@ -15,7 +15,7 @@ fun Match.isCoachHubVisible(now: Instant = Instant.now()): Boolean {
if (streamCompleted) return false
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> =
@@ -114,34 +114,38 @@ class CompactScoreboardElement : OverlayElement {
val accentColor = if (isHome) compact.homeAccentColor else compact.awayAccentColor
val logoUrl = if (isHome) compact.homeLogoUrl else compact.awayLogoUrl
val logo = OverlayLogoCache.get(logoUrl)
val rawName = if (isHome) compact.homeTeamName else compact.awayTeamName
val name = ellipsize(rawName.trim(), labelPaint, maxWidth * 0.55f)
val rawName = (if (isHome) compact.homeTeamName else compact.awayTeamName).trim()
val barHalfH = logoSize * 0.45f
val textY = centerY + labelPaint.textSize * 0.35f
if (isHome) {
var x = rectLeft
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
if (logo != null) {
val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f)
canvas.drawBitmap(logo, null, dest, logoPaint)
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 {
// Specchio del lato casa: barra, logo, poi nome a sinistra del logo.
var x = rectRight
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
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) {
x -= logoSize
val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f)
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.unit.dp
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.repository.MatchSessionLauncher
import com.matchlivetv.match_live_tv.domain.Match
@@ -161,7 +159,7 @@ fun MatchesScreen(
(match.hasActiveSession && match.activeSessionStatus == "idle")
}
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 calendarMatches = (scheduledMatches + draftMatches).distinctBy { it.id }
@@ -10,7 +10,22 @@ class ApiInstantTest {
fun parseApiInstant_railsOffsetWithMillis() {
val instant = parseApiInstant("2026-06-06T20:00:00.000+02:00")
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
@@ -19,6 +19,14 @@ enum ApiInstant {
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? {
guard let date = parse(raw) else { return nil }
let f = DateFormatter()
@@ -12,13 +12,17 @@ enum MatchHubFilter {
}
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 {
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 {
!match.hasActiveSession && (match.scheduledAt == nil || match.scheduledAt?.isEmpty == true)
}
@@ -67,32 +67,37 @@ struct CompactScoreboardElement: OverlayElement {
let accent = isHome ? compact.homeAccentColor : compact.awayAccentColor
let logoUrl = isHome ? compact.homeLogoUrl : compact.awayLogoUrl
let logo = OverlayLogoCache.get(logoUrl)
let rawName = isHome ? compact.homeTeamName : compact.awayTeamName
let name = ellipsize(rawName.trimmingCharacters(in: .whitespacesAndNewlines), font: labelFont, maxWidth: maxWidth * 0.55)
let rawName = (isHome ? compact.homeTeamName : compact.awayTeamName)
.trimmingCharacters(in: .whitespacesAndNewlines)
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 {
var x = rectLeft
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)
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)))
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 {
// Specchio del lato casa: barra, logo, poi nome a sinistra del logo.
var x = rectRight
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)
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 {
x -= 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] {
matches.filter { !$0.hasActiveSession && MatchHubFilter.isScheduledFuture($0) }
matches.filter { !$0.hasActiveSession && !($0.scheduledAt ?? "").isEmpty }
}
private var calendarMatches: [Match] {