Files
iceglass/Shared/Models/BracketModel.swift
T
rzen 541aa3d52c 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 "-:-"
2026-05-30 08:21:28 -04:00

91 lines
2.9 KiB
Swift

//
// BracketModel.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
struct PlayoffBracket: Codable {
let series: [Series]
struct Series: Codable {
let seriesUrl: String?
let seriesLetter: String
let playoffRound: Int
let seriesTitle: String
let topSeedTeam: Team?
let bottomSeedTeam: Team?
let topSeedWins: Int
let bottomSeedWins: Int
struct Team: Codable {
let abbrev: String
}
/// True once both teams are known (i.e. the series is actually matched up).
var isMatched: Bool { topSeedTeam != nil && bottomSeedTeam != nil }
var isOver: Bool { topSeedWins == 4 || bottomSeedWins == 4 }
/// 1-based game number of the next unplayed game in the series (nil if series is over).
var nextGameNumber: Int? {
isOver ? nil : topSeedWins + bottomSeedWins + 1
}
/// Abbrev of whichever side has reached 4 wins, nil if series ongoing.
var winner: String? {
guard isOver else { return nil }
return topSeedWins == 4 ? topSeedTeam?.abbrev : bottomSeedTeam?.abbrev
}
/// Abbrev of the eliminated side, nil if the series is ongoing.
var loser: String? {
guard isOver else { return nil }
return topSeedWins == 4 ? bottomSeedTeam?.abbrev : topSeedTeam?.abbrev
}
/// Absolute URL for the NHL.com series page, or nil if the bracket doesn't provide one.
var fullSeriesUrl: String? {
seriesUrl.map { "https://www.nhl.com\($0)" }
}
func involves(away: String, home: String) -> Bool {
guard let top = topSeedTeam, let bottom = bottomSeedTeam else { return false }
let pair = Set([away, home])
return pair == Set([top.abbrev, bottom.abbrev])
}
}
/// Lowest round number that still has at least one matched, live series.
var currentRound: Int? {
series
.filter { $0.isMatched && !$0.isOver }
.map(\.playoffRound)
.min()
}
/// All matched series in the current round.
var currentRoundSeries: [Series] {
guard let round = currentRound else { return [] }
return matchedSeries(inRound: round)
}
/// Rounds that have at least one matched series, ascending (1current).
var activeRounds: [Int] {
Set(series.filter(\.isMatched).map(\.playoffRound)).sorted()
}
/// All matched series in the given round, sorted by series letter.
func matchedSeries(inRound round: Int) -> [Series] {
series
.filter { $0.playoffRound == round && $0.isMatched }
.sorted { $0.seriesLetter < $1.seriesLetter }
}
func series(for game: Scoreboard.Game) -> Series? {
series.first { $0.involves(away: game.awayTeam.abbrev, home: game.homeTeam.abbrev) }
}
}