Show full playoff bracket, mark series results, harden API decoding
Playoffs: - List every round played so far (Round 1 → current) instead of only the current round, on both macOS menu and iPhone - Strike through the eliminated team's tricode in a finished series and drop the now-redundant "(Final … wins)" tag on completed earlier rounds - Refetch the bracket when a finished game implies more completed games than the cached bracket records, so the series score and round no longer get stuck on stale data after cold launch or the NHL bracket endpoint's lag API robustness: - Tolerate optional gameCenterLink/startTimeUTC on TBD playoff matchups so the scoreboard decode no longer aborts - Reject API state regressions via a monotonic FUT→…→OFF progression rank so a brief glitch can't downgrade a finished game back to "-:-"
This commit is contained in:
@@ -127,12 +127,20 @@ class MainService: @unchecked Sendable {
|
||||
return allGamesByDate.contains { $0.date == today && !$0.games.isEmpty }
|
||||
}
|
||||
|
||||
/// Series in the current playoff round paired with each one's next scheduled
|
||||
/// game from the fetched window (if any). Empty during regular season.
|
||||
var currentRoundSeriesItems: [RoundSeriesItem] {
|
||||
/// Every active playoff round paired with its matched series, ascending by
|
||||
/// round (1…current). Each series carries its next scheduled game from the
|
||||
/// fetched window (if any). Empty during the regular season.
|
||||
var roundSeriesGroups: [RoundGroup] {
|
||||
guard let bracket = bracket else { return [] }
|
||||
return bracket.activeRounds.map { round in
|
||||
RoundGroup(round: round, items: roundSeriesItems(for: bracket.matchedSeries(inRound: round)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pairs each series with its next unplayed game from the fetched window.
|
||||
private func roundSeriesItems(for seriesList: [PlayoffBracket.Series]) -> [RoundSeriesItem] {
|
||||
let windowGames = allGamesByDate.flatMap(\.games)
|
||||
return bracket.currentRoundSeries.map { series in
|
||||
return seriesList.map { series in
|
||||
let nextGame = windowGames
|
||||
.filter { !$0.parsedGameState.isOver && series.involves(away: $0.awayTeam.abbrev, home: $0.homeTeam.abbrev) }
|
||||
.min { $0.date < $1.date }
|
||||
@@ -140,6 +148,11 @@ class MainService: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct RoundGroup {
|
||||
let round: Int
|
||||
let items: [RoundSeriesItem]
|
||||
}
|
||||
|
||||
struct RoundSeriesItem {
|
||||
let series: PlayoffBracket.Series
|
||||
let nextGame: Scoreboard.Game?
|
||||
@@ -181,8 +194,8 @@ class MainService: @unchecked Sendable {
|
||||
let tomorrow = Date.tomorrowET
|
||||
let windowDates = Set([yesterday, today, tomorrow])
|
||||
let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.date) }
|
||||
self.allGamesByDate = filtered
|
||||
self.updateSnapshots(from: filtered)
|
||||
self.allGamesByDate = mergeWithMonotonicState(filtered)
|
||||
self.updateSnapshots(from: self.allGamesByDate)
|
||||
}
|
||||
self.standings = snapshot.standings
|
||||
self.bracket = snapshot.bracket
|
||||
@@ -225,13 +238,14 @@ class MainService: @unchecked Sendable {
|
||||
let windowDates = Set([yesterday, today, tomorrow])
|
||||
|
||||
let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.date) }
|
||||
let merged = self.mergeWithMonotonicState(filtered)
|
||||
|
||||
if !self.isFirstFetch {
|
||||
self.detectChanges(in: filtered)
|
||||
self.detectChanges(in: merged)
|
||||
}
|
||||
|
||||
self.allGamesByDate = filtered
|
||||
self.updateSnapshots(from: filtered)
|
||||
self.allGamesByDate = merged
|
||||
self.updateSnapshots(from: merged)
|
||||
self.lastUpdated = Date()
|
||||
|
||||
if self.isFirstFetch {
|
||||
@@ -368,6 +382,29 @@ class MainService: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject API responses that downgrade a known game to an earlier state in
|
||||
/// the FUT→PRE→LIVE→CRIT→OVER→FINAL→OFF progression. The NHL `/scoreboard/now`
|
||||
/// CDN occasionally serves yesterday's finished games as `FUT` during the
|
||||
/// daily focusedDate rollover; without this guard, iOS opens cold in the
|
||||
/// morning, sees that one bad response, and shows `-:-` until the API
|
||||
/// self-corrects. macOS polls through it but iOS only gets one chance.
|
||||
private func mergeWithMonotonicState(_ incoming: [Scoreboard.GameDay]) -> [Scoreboard.GameDay] {
|
||||
let previousById = Dictionary(
|
||||
uniqueKeysWithValues: allGamesByDate.flatMap(\.games).map { ($0.id, $0) }
|
||||
)
|
||||
return incoming.map { day in
|
||||
let games = day.games.map { newGame -> Scoreboard.Game in
|
||||
guard let prev = previousById[newGame.id] else { return newGame }
|
||||
let prevRank = GameState.progressionRank(of: prev.gameState)
|
||||
let newRank = GameState.progressionRank(of: newGame.gameState)
|
||||
guard prevRank > newRank else { return newGame }
|
||||
logger.warning("Rejecting state regression for game \(newGame.id) (\(newGame.awayTeam.abbrev)@\(newGame.homeTeam.abbrev)): API \(newGame.gameState) < held \(prev.gameState)")
|
||||
return prev
|
||||
}
|
||||
return Scoreboard.GameDay(date: day.date, games: games)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSnapshots(from gameDays: [Scoreboard.GameDay]) {
|
||||
for gameDay in gameDays {
|
||||
for game in gameDay.games {
|
||||
@@ -442,8 +479,8 @@ class MainService: @unchecked Sendable {
|
||||
// MARK: - Playoff Bracket
|
||||
|
||||
private func refreshBracketIfNeeded(from gameDays: [Scoreboard.GameDay], force: Bool) async {
|
||||
let playoffGame = gameDays.flatMap(\.games).first { $0.gameType == 3 }
|
||||
guard let playoffGame = playoffGame else {
|
||||
let playoffGames = gameDays.flatMap(\.games).filter { $0.gameType == 3 }
|
||||
guard let playoffGame = playoffGames.first else {
|
||||
bracket = nil
|
||||
return
|
||||
}
|
||||
@@ -468,8 +505,26 @@ class MainService: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
if bracket == nil || seasonChanged || force {
|
||||
if bracket == nil || seasonChanged || force || isBracketStale(against: playoffGames) {
|
||||
await bracketApi?.fetch()
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a finished playoff game in the window implies more completed
|
||||
/// games than the cached bracket has recorded for that series — i.e. the
|
||||
/// bracket is behind the scoreboard. Catches both a stale cached bracket
|
||||
/// loaded on cold launch and the lag between a game going final and the NHL
|
||||
/// bracket endpoint updating its win counts. Self-clears once the bracket
|
||||
/// catches up, so it stops triggering fetches as soon as the two agree.
|
||||
private func isBracketStale(against playoffGames: [Scoreboard.Game]) -> Bool {
|
||||
guard let bracket = bracket else { return false }
|
||||
for game in playoffGames where game.parsedGameState.isOver {
|
||||
guard let context = game.playoffContext,
|
||||
let series = bracket.series(for: game) else { continue }
|
||||
if series.topSeedWins + series.bottomSeedWins < context.gameInSeries {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user