diff --git a/backend/app/controllers/api/v1/matches_controller.rb b/backend/app/controllers/api/v1/matches_controller.rb index 90fcf2c..40b6647 100644 --- a/backend/app/controllers/api/v1/matches_controller.rb +++ b/backend/app/controllers/api/v1/matches_controller.rb @@ -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? diff --git a/backend/app/models/match.rb b/backend/app/models/match.rb index 7690117..3eff3db 100644 --- a/backend/app/models/match.rb +++ b/backend/app/models/match.rb @@ -98,10 +98,18 @@ class Match < ApplicationRecord active = active_stream_session return true if active&.resumable? return true if active&.idle? - 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 diff --git a/backend/spec/models/match_scheduling_spec.rb b/backend/spec/models/match_scheduling_spec.rb index 65529c2..92614a4 100644 --- a/backend/spec/models/match_scheduling_spec.rb +++ b/backend/spec/models/match_scheduling_spec.rb @@ -83,15 +83,51 @@ 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 "mostra partita di oggi già passata d'orario se non è stata trasmessa" do match = team.matches.create!( - opponent_name: "Missed", + 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 diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ApiInstant.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ApiInstant.kt index a6d8798..485ee6a 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ApiInstant.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/ApiInstant.kt @@ -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) +} diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchHubFilter.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchHubFilter.kt index c13ad42..dcc513a 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchHubFilter.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/domain/MatchHubFilter.kt @@ -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.coachHubVisible(now: Instant = Instant.now()): List = diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt index 13ff2e9..a5236fc 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt @@ -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 } diff --git a/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/core/ApiInstantTest.kt b/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/core/ApiInstantTest.kt index ced566c..4d64200 100644 --- a/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/core/ApiInstantTest.kt +++ b/native/android/app/src/test/kotlin/com/matchlivetv/match_live_tv/core/ApiInstantTest.kt @@ -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 diff --git a/native/ios/MatchLiveTv/Core/ApiInstant.swift b/native/ios/MatchLiveTv/Core/ApiInstant.swift index 3471e16..78bd126 100644 --- a/native/ios/MatchLiveTv/Core/ApiInstant.swift +++ b/native/ios/MatchLiveTv/Core/ApiInstant.swift @@ -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() diff --git a/native/ios/MatchLiveTv/Domain/MatchHubFilter.swift b/native/ios/MatchLiveTv/Domain/MatchHubFilter.swift index ce9d2ca..2415d21 100644 --- a/native/ios/MatchLiveTv/Domain/MatchHubFilter.swift +++ b/native/ios/MatchLiveTv/Domain/MatchHubFilter.swift @@ -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) } diff --git a/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift b/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift index ce78822..33682c7 100644 --- a/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift +++ b/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift @@ -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] {