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:
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## May 2026
|
||||
|
||||
- Mark the result of a finished playoff series by striking through the eliminated team's tricode, and drop the redundant "(Final … wins)" tag on completed earlier rounds (both macOS menu and iPhone)
|
||||
- Playoff section now lists every round played so far (ROUND 1 → current), in ascending order, instead of just the current round — on both macOS menu and iPhone
|
||||
- Re-fetch the playoff bracket whenever a finished game in the window implies more completed games than the cached bracket records, so the ROUND series score and round advancement no longer get stuck on stale data after a cold launch or the NHL bracket endpoint's post-final update lag
|
||||
- Tolerate optional `gameCenterLink` and `startTimeUTC` on TBD playoff matchups so the scoreboard decode no longer aborts (was leaving in-memory state frozen and showing stale yesterday rows)
|
||||
- Reject API state regressions: never let a known game downgrade along the FUT→PRE→LIVE→CRIT→OVER→FINAL→OFF progression, so a brief `/scoreboard/now` glitch can't turn a finished game back into "-:-" on iOS cold launch
|
||||
|
||||
## April 2026
|
||||
|
||||
- New iPhone target (`IceGlass-iOS`): single-page SwiftUI app mirroring the macOS menu's playoff round + yesterday/today/tomorrow content, with a gear-icon settings sheet (display option + IndieAbout)
|
||||
|
||||
@@ -40,14 +40,9 @@ final class ScoreboardViewModel {
|
||||
return MainService.shared.gamesByDate
|
||||
}
|
||||
|
||||
var currentRoundSeriesItems: [MainService.RoundSeriesItem] {
|
||||
var roundSeriesGroups: [MainService.RoundGroup] {
|
||||
_ = revision
|
||||
return MainService.shared.currentRoundSeriesItems
|
||||
}
|
||||
|
||||
var currentRoundNumber: Int? {
|
||||
_ = revision
|
||||
return MainService.shared.bracket?.currentRound
|
||||
return MainService.shared.roundSeriesGroups
|
||||
}
|
||||
|
||||
/// Bridge object that forwards MainService callbacks back to this view model.
|
||||
|
||||
@@ -106,7 +106,8 @@ struct GameRow: View {
|
||||
}
|
||||
|
||||
private func open() {
|
||||
guard let url = URL(string: game.gameCenterUrl) else { return }
|
||||
guard let urlString = game.gameCenterUrl,
|
||||
let url = URL(string: urlString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,17 +21,21 @@ struct MainView: View {
|
||||
)
|
||||
.padding(.horizontal)
|
||||
|
||||
if !viewModel.currentRoundSeriesItems.isEmpty,
|
||||
let round = viewModel.currentRoundNumber {
|
||||
PlayoffRoundSection(
|
||||
round: round,
|
||||
items: viewModel.currentRoundSeriesItems
|
||||
)
|
||||
.padding(.horizontal)
|
||||
let roundGroups = viewModel.roundSeriesGroups
|
||||
let latestRound = roundGroups.last?.round
|
||||
ForEach(roundGroups, id: \.round) { group in
|
||||
if !group.items.isEmpty {
|
||||
PlayoffRoundSection(
|
||||
round: group.round,
|
||||
items: group.items,
|
||||
showStatus: group.round == latestRound
|
||||
)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
let gameDays = viewModel.gamesByDate
|
||||
if gameDays.isEmpty && viewModel.currentRoundSeriesItems.isEmpty {
|
||||
if gameDays.isEmpty && roundGroups.isEmpty {
|
||||
emptyState
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 40)
|
||||
|
||||
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
struct PlayoffRoundSection: View {
|
||||
let round: Int
|
||||
let items: [MainService.RoundSeriesItem]
|
||||
var showStatus: Bool = true
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -20,7 +21,7 @@ struct PlayoffRoundSection: View {
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(items, id: \.series.seriesLetter) { item in
|
||||
SeriesRow(item: item)
|
||||
SeriesRow(item: item, showStatus: showStatus)
|
||||
if item.series.seriesLetter != items.last?.series.seriesLetter {
|
||||
Divider()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import SwiftUI
|
||||
|
||||
struct SeriesRow: View {
|
||||
let item: MainService.RoundSeriesItem
|
||||
/// Completed earlier rounds hide the status/next-game column; the struck-out
|
||||
/// loser already conveys the result.
|
||||
var showStatus: Bool = true
|
||||
|
||||
private static let logoSize: CGFloat = 40
|
||||
|
||||
@@ -17,7 +20,9 @@ struct SeriesRow: View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
matchupBlock
|
||||
Spacer(minLength: 8)
|
||||
rightContent
|
||||
if showStatus {
|
||||
rightContent
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
@@ -34,6 +39,7 @@ struct SeriesRow: View {
|
||||
Text(bottom)
|
||||
.font(.title3.monospaced())
|
||||
.fontWeight(.semibold)
|
||||
.strikethrough(item.series.loser == bottom)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
@@ -47,6 +53,7 @@ struct SeriesRow: View {
|
||||
Text(top)
|
||||
.font(.title3.monospaced())
|
||||
.fontWeight(.semibold)
|
||||
.strikethrough(item.series.loser == top)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
|
||||
@@ -91,9 +91,10 @@ class MenuManager: @unchecked Sendable {
|
||||
let monoFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
||||
let boldFont = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .bold)
|
||||
|
||||
let roundItems = mainService.currentRoundSeriesItems
|
||||
if !roundItems.isEmpty, let round = mainService.bracket?.currentRound {
|
||||
let headerText = "ROUND \(round)"
|
||||
let roundGroups = mainService.roundSeriesGroups
|
||||
let latestRound = roundGroups.last?.round
|
||||
for group in roundGroups where !group.items.isEmpty {
|
||||
let headerText = "ROUND \(group.round)"
|
||||
let headerItem = NSMenuItem(title: headerText, action: nil, keyEquivalent: "")
|
||||
headerItem.attributedTitle = NSAttributedString(
|
||||
string: headerText,
|
||||
@@ -101,8 +102,8 @@ class MenuManager: @unchecked Sendable {
|
||||
)
|
||||
menu.addItem(headerItem)
|
||||
|
||||
for item in roundItems {
|
||||
menu.addItem(createRoundSeriesItem(for: item, font: monoFont))
|
||||
for item in group.items {
|
||||
menu.addItem(createRoundSeriesItem(for: item, showStatus: group.round == latestRound, font: monoFont))
|
||||
}
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
@@ -254,7 +255,8 @@ class MenuManager: @unchecked Sendable {
|
||||
|
||||
@objc private func openGame(_ sender: NSMenuItem) {
|
||||
guard let game = sender.representedObject as? Scoreboard.Game,
|
||||
let url = URL(string: game.gameCenterUrl) else { return }
|
||||
let urlString = game.gameCenterUrl,
|
||||
let url = URL(string: urlString) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
@@ -352,32 +354,50 @@ class MenuManager: @unchecked Sendable {
|
||||
return [item, altItem]
|
||||
}
|
||||
|
||||
private func createRoundSeriesItem(for roundItem: MainService.RoundSeriesItem, font: NSFont) -> NSMenuItem {
|
||||
private func createRoundSeriesItem(for roundItem: MainService.RoundSeriesItem, showStatus: Bool, font: NSFont) -> NSMenuItem {
|
||||
let series = roundItem.series
|
||||
let top = series.topSeedTeam?.abbrev ?? "TBD"
|
||||
let bottom = series.bottomSeedTeam?.abbrev ?? "TBD"
|
||||
let matchup = "\(bottom) @ \(top)"
|
||||
let score = "\(series.bottomSeedWins)-\(series.topSeedWins)"
|
||||
|
||||
let statusTag: String
|
||||
let trailing: String
|
||||
if let winner = series.winner {
|
||||
statusTag = "(Final \(winner) wins)"
|
||||
trailing = ""
|
||||
} else if let n = series.nextGameNumber {
|
||||
statusTag = "(Game \(n))"
|
||||
trailing = roundItem.nextGame?.nextGameLabel ?? ""
|
||||
// Completed earlier rounds show just the matchup and score — the struck-out
|
||||
// loser already conveys the result, so the "(Final … wins)" tag is dropped.
|
||||
let title: String
|
||||
if showStatus {
|
||||
let statusTag: String
|
||||
let trailing: String
|
||||
if let winner = series.winner {
|
||||
statusTag = "(Final \(winner) wins)"
|
||||
trailing = ""
|
||||
} else if let n = series.nextGameNumber {
|
||||
statusTag = "(Game \(n))"
|
||||
trailing = roundItem.nextGame?.nextGameLabel ?? ""
|
||||
} else {
|
||||
statusTag = ""
|
||||
trailing = ""
|
||||
}
|
||||
let tagColumn = statusTag.padding(toLength: 20, withPad: " ", startingAt: 0)
|
||||
title = " \(matchup) \(score) \(tagColumn)\(trailing)"
|
||||
} else {
|
||||
statusTag = ""
|
||||
trailing = ""
|
||||
title = " \(matchup) \(score)"
|
||||
}
|
||||
let tagColumn = statusTag.padding(toLength: 20, withPad: " ", startingAt: 0)
|
||||
let title = " \(matchup) \(score) \(tagColumn)\(trailing)"
|
||||
|
||||
let item = NSMenuItem(title: title, action: #selector(openSeriesPage(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.representedObject = series.fullSeriesUrl
|
||||
item.attributedTitle = NSAttributedString(string: title, attributes: [.font: font])
|
||||
|
||||
let attributed = NSMutableAttributedString(string: title, attributes: [.font: font])
|
||||
// Strike through the eliminated team's tricode, searching only within the
|
||||
// matchup region so the same abbrev inside the "(Final … wins)" tag stays plain.
|
||||
if let loser = series.loser {
|
||||
let matchupRange = NSRange(location: 2, length: (matchup as NSString).length)
|
||||
let loserRange = (title as NSString).range(of: loser, options: [], range: matchupRange)
|
||||
if loserRange.location != NSNotFound {
|
||||
attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: loserRange)
|
||||
}
|
||||
}
|
||||
item.attributedTitle = attributed
|
||||
return item
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@ class NotificationManager: @unchecked Sendable {
|
||||
content.body = "\(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)"
|
||||
content.sound = .default
|
||||
content.interruptionLevel = .active
|
||||
content.userInfo = ["url": game.gameCenterUrl]
|
||||
if let url = game.gameCenterUrl {
|
||||
content.userInfo = ["url": url]
|
||||
}
|
||||
|
||||
// Attach away team logo (visiting team at the other team's arena)
|
||||
if let attachment = teamLogoAttachment(for: game.awayTeam.abbrev) {
|
||||
@@ -120,7 +122,9 @@ class NotificationManager: @unchecked Sendable {
|
||||
content.body = "\(game.awayTeam.abbrev) \(awayScore) : \(game.homeTeam.abbrev) \(homeScore)"
|
||||
content.sound = UNNotificationSound(named: UNNotificationSoundName("Glass"))
|
||||
content.interruptionLevel = .active
|
||||
content.userInfo = ["url": game.gameCenterUrl]
|
||||
if let url = game.gameCenterUrl {
|
||||
content.userInfo = ["url": url]
|
||||
}
|
||||
|
||||
// Attach scoring team's logo
|
||||
if let attachment = teamLogoAttachment(for: scoringTeam.abbrev) {
|
||||
@@ -177,7 +181,9 @@ class NotificationManager: @unchecked Sendable {
|
||||
content.body = "\(winner.abbrev) \(winnerScore) — \(loser.abbrev) \(loserScore)"
|
||||
content.sound = .default
|
||||
content.interruptionLevel = .active
|
||||
content.userInfo = ["url": game.gameCenterUrl]
|
||||
if let url = game.gameCenterUrl {
|
||||
content.userInfo = ["url": url]
|
||||
}
|
||||
|
||||
// Attach winning team's logo
|
||||
if let attachment = teamLogoAttachment(for: winner.abbrev) {
|
||||
|
||||
@@ -8,7 +8,7 @@ NHL game situational awareness for macOS (menu bar) and iPhone (single-page app)
|
||||
- NHL shield icon in the menu bar with game count
|
||||
- Shows games from yesterday, today, and tomorrow grouped by date (configurable)
|
||||
- Regular-season rows show league-wide game number (`#547 NYR @ WAS …`)
|
||||
- During playoffs, a ROUND section lists every active series with its series score, next game-in-series number, and upcoming tip-off time
|
||||
- During playoffs, ROUND sections list every round played so far (Round 1 through the current round) and each round's series with its series score, next game-in-series number, and upcoming tip-off time
|
||||
- Game format: `NYR @ WAS 0:2 (FINAL)` / `DAL @ TOR Today @ 7:30 PM`
|
||||
- Click a game to open NHL GameCenter; option-click for NHL Videocast
|
||||
- Goal scored notifications with scoring team logo
|
||||
|
||||
@@ -40,6 +40,12 @@ struct PlayoffBracket: Codable {
|
||||
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)" }
|
||||
@@ -63,7 +69,17 @@ struct PlayoffBracket: Codable {
|
||||
/// All matched series in the current round.
|
||||
var currentRoundSeries: [Series] {
|
||||
guard let round = currentRound else { return [] }
|
||||
return series
|
||||
return matchedSeries(inRound: round)
|
||||
}
|
||||
|
||||
/// Rounds that have at least one matched series, ascending (1…current).
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -42,6 +42,27 @@ enum GameState: String, Codable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotonic ordering along the FUT→PRE→LIVE→CRIT→OVER→FINAL→OFF progression.
|
||||
/// Used to detect — and reject — API regressions that briefly downgrade a
|
||||
/// finished game back to FUT during the daily focusedDate rollover.
|
||||
var progressionRank: Int {
|
||||
switch self {
|
||||
case .future: return 0
|
||||
case .pre: return 1
|
||||
case .live: return 2
|
||||
case .crit: return 3
|
||||
case .over: return 4
|
||||
case .final_: return 5
|
||||
case .official: return 6
|
||||
}
|
||||
}
|
||||
|
||||
/// Same ranking, addressable by the raw API string. Unknown states get -1
|
||||
/// so any known state replaces them.
|
||||
static func progressionRank(of rawState: String) -> Int {
|
||||
GameState(rawValue: rawState)?.progressionRank ?? -1
|
||||
}
|
||||
|
||||
var pollingInterval: PollingInterval {
|
||||
switch self {
|
||||
case .future:
|
||||
|
||||
@@ -26,8 +26,10 @@ struct Scoreboard: Codable {
|
||||
let season: Int
|
||||
let gameType: Int
|
||||
let gameDate: String
|
||||
let gameCenterLink: String
|
||||
let startTimeUTC: String
|
||||
/// Missing on TBD playoff matchups (opponent not yet decided).
|
||||
let gameCenterLink: String?
|
||||
/// Missing on TBD playoff matchups not yet scheduled.
|
||||
let startTimeUTC: String?
|
||||
let gameState: String
|
||||
let gameScheduleState: String
|
||||
let awayTeam: Team
|
||||
@@ -62,19 +64,23 @@ struct Scoreboard: Codable {
|
||||
}
|
||||
|
||||
var date: Date {
|
||||
ISO8601DateFormatter().date(from: startTimeUTC) ?? .now
|
||||
guard let startTimeUTC else { return .distantFuture }
|
||||
return ISO8601DateFormatter().date(from: startTimeUTC) ?? .now
|
||||
}
|
||||
|
||||
var gameCenterUrl: String {
|
||||
"https://www.nhl.com\(gameCenterLink)"
|
||||
var gameCenterUrl: String? {
|
||||
guard let gameCenterLink else { return nil }
|
||||
return "https://www.nhl.com\(gameCenterLink)"
|
||||
}
|
||||
|
||||
var videocastUrl: String {
|
||||
"https://videocast.nhl.com/game/\(id)/usnded?autoplay=true"
|
||||
}
|
||||
|
||||
/// Time string in ET, right-aligned to 8 chars (e.g. " 9:30 PM", "10:00 PM")
|
||||
/// Time string in ET, right-aligned to 8 chars (e.g. " 9:30 PM", "10:00 PM").
|
||||
/// Empty when the game has no scheduled start (TBD opponent).
|
||||
var startTimeET: String {
|
||||
guard startTimeUTC != nil else { return "" }
|
||||
let raw = date.formatDateET(format: "h:mm a")
|
||||
return raw.count < 8
|
||||
? String(repeating: " ", count: 8 - raw.count) + raw
|
||||
|
||||
@@ -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