// // 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 } /// 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 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) } } }