Files
iceglass/Shared/Models/ScoreboardModel.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

139 lines
4.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// ScoreboardModel.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
struct Scoreboard: Codable {
let focusedDate: String
let focusedDateCount: Int
let gamesByDate: [GameDay]
struct GameDay: Codable {
let date: String // "YYYY-MM-DD"
let games: [Game]
}
struct Game: Codable, Equatable {
static func == (lhs: Game, rhs: Game) -> Bool {
lhs.id == rhs.id
}
let id: Int
let season: Int
let gameType: Int
let gameDate: 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
let homeTeam: Team
let period: Int?
let periodDescriptor: PeriodDescriptor?
struct LocalizedString: Codable {
let `default`: String
}
struct Team: Codable {
let id: Int
let name: LocalizedString
let commonName: LocalizedString
let abbrev: String
let score: Int?
let record: String?
let logo: String
}
struct PeriodDescriptor: Codable {
let number: Int
let periodType: String
let maxRegulationPeriods: Int
}
// MARK: - Computed Properties
var parsedGameState: GameState {
GameState(rawValue: gameState) ?? .future
}
var date: Date {
guard let startTimeUTC else { return .distantFuture }
return ISO8601DateFormatter().date(from: startTimeUTC) ?? .now
}
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").
/// 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
: raw
}
/// Formatted menu title:
/// "NYR @ WAS 0: 2 9:30 PM FINAL" (finished/live padded score + time + state tag)
/// "DAL @ TOR 7:30 PM" (future no score gap, no tag)
var menuTitle: String {
let state = parsedGameState
let matchup = "\(awayTeam.abbrev) @ \(homeTeam.abbrev)"
let tag = state.shortTag
let tagSuffix = tag.isEmpty ? "" : " \(tag)"
if state.isFuture {
return "\(matchup) \(startTimeET)\(tagSuffix)"
}
let aScore = String(format: "%2d", awayTeam.score ?? 0)
let hScore = String(format: "%-2d", homeTeam.score ?? 0)
return "\(matchup) \(aScore):\(hScore) \(startTimeET)\(tagSuffix)"
}
/// Sequential game number encoded in the last 4 digits of `id`.
/// Regular season: 1~1312. Playoffs: 111417 (`RSG` round/series/game).
var seasonGameNumber: Int { id % 10_000 }
/// Parsed playoff context; nil for non-playoff games.
var playoffContext: PlayoffContext? {
guard gameType == 3 else { return nil }
let n = id % 1000
return PlayoffContext(
round: n / 100,
seriesInRound: (n / 10) % 10,
gameInSeries: n % 10
)
}
struct PlayoffContext: Equatable {
let round: Int
let seriesInRound: Int
let gameInSeries: Int
/// AO by cumulative position (R1 S1 = A, R1 S8 = H, R2 S1 = I, , R4 S1 = O).
var seriesLetter: String {
let roundStartIndex = [0, 0, 8, 12, 14]
guard round >= 1, round <= 4 else { return "" }
let index = roundStartIndex[round] + (seriesInRound - 1)
guard index >= 0, index < 15 else { return "" }
return String(UnicodeScalar(65 + index)!)
}
}
}
}