541aa3d52c
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 "-:-"
139 lines
4.4 KiB
Swift
139 lines
4.4 KiB
Swift
//
|
||
// 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: 111–417 (`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
|
||
|
||
/// A…O 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)!)
|
||
}
|
||
}
|
||
}
|
||
}
|