Add iPhone target with shared data layer and persistent cache
Two-target restructure: shared sources (models, services, settings, extensions, team logos) move into Shared/, consumed by both the existing macOS menu bar app and a new iOS app. MainService no longer imports AppKit — platform code attaches via a MainServiceObserver protocol (MacObserverAdapter wires back to MenuManager / StatusItemManager / NotificationManager). iPhone app is a single SwiftUI page mirroring the macOS menu (playoff round + yesterday/today/tomorrow), with a gear-icon settings sheet (display option + IndieAbout for license/changelog). Persistent JSON snapshot in Application Support paints last-known data on cold launch; "Updated …" header escalates secondary → orange (>5min) → red (>30min) so staleness is visually unmistakable. Foreground polling, scenePhase refresh, and pull-to-refresh; no notifications on iOS in v1.
@@ -16,11 +16,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
)
|
||||
|
||||
private var mainService = MainService.shared
|
||||
private let observerAdapter = MacObserverAdapter()
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
logger.info("applicationDidFinishLaunching")
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
// Install MainService → AppKit bridge before any data flows in.
|
||||
mainService.observer = observerAdapter
|
||||
|
||||
// Force re-register with Launch Services to refresh cached icon
|
||||
LSRegisterURL(Bundle.main.bundleURL as CFURL, true)
|
||||
|
||||
@@ -48,3 +52,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges MainService callbacks to the macOS-only managers so MainService
|
||||
/// stays AppKit-free and shareable with the iOS target.
|
||||
@MainActor
|
||||
final class MacObserverAdapter: MainServiceObserver {
|
||||
func mainServiceDidUpdate() {
|
||||
StatusItemManager.shared.updateStatusText(MainService.shared.statusBarText)
|
||||
MenuManager.shared.scoreboardChanged()
|
||||
}
|
||||
|
||||
func mainServiceDidDetectGameStart(_ game: Scoreboard.Game) {
|
||||
NotificationManager.shared.notifyGameStarted(game)
|
||||
}
|
||||
|
||||
func mainServiceDidDetectGoal(
|
||||
_ game: Scoreboard.Game,
|
||||
scoringTeam: Scoreboard.Game.Team,
|
||||
scorer: GoalScorer?
|
||||
) {
|
||||
NotificationManager.shared.notifyGoalScored(game, scoringTeam: scoringTeam, scorer: scorer)
|
||||
}
|
||||
|
||||
func mainServiceDidDetectGameEnd(_ game: Scoreboard.Game) {
|
||||
NotificationManager.shared.notifyGameEnded(game)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// Date+easternTimeZone.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
static var easternTimeZone: TimeZone {
|
||||
TimeZone(identifier: "America/New_York")!
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//
|
||||
// Date+etCalendar.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
static func etCalendar() -> Calendar {
|
||||
var calendar = Calendar.current
|
||||
calendar.timeZone = Date.easternTimeZone
|
||||
return calendar
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//
|
||||
// Date+formatDateET.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
func formatDateET(format: String = "MMMM, d yyyy @ h:mm a z") -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeZone = TimeZone(identifier: "America/New_York")
|
||||
formatter.dateFormat = format
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
|
||||
static var ISO8601: String {
|
||||
"yyyy-MM-dd'T'HH:mm:ssZ"
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//
|
||||
// Date+gameWindow.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
/// Returns YYYY-MM-DD string for yesterday in Eastern Time
|
||||
static var yesterdayET: String {
|
||||
let calendar = etCalendar()
|
||||
let yesterday = calendar.date(byAdding: .day, value: -1, to: .now)!
|
||||
return formatDateString(yesterday)
|
||||
}
|
||||
|
||||
/// Returns YYYY-MM-DD string for today in Eastern Time
|
||||
static var todayET: String {
|
||||
formatDateString(.now)
|
||||
}
|
||||
|
||||
/// Returns YYYY-MM-DD string for tomorrow in Eastern Time
|
||||
static var tomorrowET: String {
|
||||
let calendar = etCalendar()
|
||||
let tomorrow = calendar.date(byAdding: .day, value: 1, to: .now)!
|
||||
return formatDateString(tomorrow)
|
||||
}
|
||||
|
||||
private static func formatDateString(_ date: Date) -> String {
|
||||
date.formatDateET(format: "yyyy-MM-dd")
|
||||
}
|
||||
|
||||
/// Returns a friendly label like "YESTERDAY", "TODAY", "TOMORROW", or a formatted date
|
||||
static func friendlyDateLabel(for dateString: String) -> String {
|
||||
if dateString == todayET { return "TODAY" }
|
||||
if dateString == yesterdayET { return "YESTERDAY" }
|
||||
if dateString == tomorrowET { return "TOMORROW" }
|
||||
|
||||
// Parse and format as weekday abbreviation + date
|
||||
let inputFormatter = DateFormatter()
|
||||
inputFormatter.dateFormat = "yyyy-MM-dd"
|
||||
inputFormatter.timeZone = easternTimeZone
|
||||
|
||||
if let date = inputFormatter.date(from: dateString) {
|
||||
return date.formatDateET(format: "EEE, MMM d")
|
||||
}
|
||||
return dateString
|
||||
}
|
||||
|
||||
/// Returns a full label like "TODAY — Sun, Apr 12"
|
||||
static func fullDateLabel(for dateString: String) -> String {
|
||||
let friendly = friendlyDateLabel(for: dateString)
|
||||
|
||||
let inputFormatter = DateFormatter()
|
||||
inputFormatter.dateFormat = "yyyy-MM-dd"
|
||||
inputFormatter.timeZone = easternTimeZone
|
||||
|
||||
if let date = inputFormatter.date(from: dateString) {
|
||||
let formatted = date.formatDateET(format: "EEE, MMM d")
|
||||
if friendly == "TODAY" || friendly == "YESTERDAY" || friendly == "TOMORROW" {
|
||||
return "\(friendly) — \(formatted)"
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
return friendly
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
//
|
||||
// TimeInterval+humanReadableTime.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension TimeInterval {
|
||||
static func humanReadableTime(from timeInterval: TimeInterval) -> String {
|
||||
let seconds = Int(timeInterval)
|
||||
|
||||
let hours = seconds / 3600
|
||||
let minutes = (seconds % 3600) / 60
|
||||
let remainingSeconds = seconds % 60
|
||||
|
||||
var components: [String] = []
|
||||
|
||||
if hours > 0 {
|
||||
components.append("\(hours) \(hours == 1 ? "hour" : "hours")")
|
||||
}
|
||||
if minutes > 0 {
|
||||
components.append("\(minutes) \(minutes == 1 ? "minute" : "minutes")")
|
||||
}
|
||||
if remainingSeconds > 0 || components.isEmpty {
|
||||
components.append("\(remainingSeconds) \(remainingSeconds == 1 ? "second" : "seconds")")
|
||||
}
|
||||
|
||||
return components.joined(separator: " ")
|
||||
}
|
||||
|
||||
func humanReadableTime() -> String {
|
||||
return TimeInterval.humanReadableTime(from: self)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//
|
||||
// Timer+startTimer.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Timer {
|
||||
static func startTimer(timer: Timer?, interval: TimeInterval, action: @escaping @Sendable (Timer) -> Void) -> Timer {
|
||||
timer?.invalidate()
|
||||
return Timer.scheduledTimer(
|
||||
withTimeInterval: interval,
|
||||
repeats: true,
|
||||
block: action
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//
|
||||
// IceGlassLogger.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import OSLog
|
||||
|
||||
struct IceGlassLogger {
|
||||
private let logger: Logger
|
||||
private let subsystem: String
|
||||
private let category: String
|
||||
|
||||
init(subsystem: String, category: String) {
|
||||
self.subsystem = subsystem
|
||||
self.category = category
|
||||
self.logger = Logger(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
func timestamp() -> String {
|
||||
Date.now.formatDateET(format: "yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
func formattedMessage(_ message: String) -> String {
|
||||
"\(timestamp()) [\(subsystem):\(category)] \(message)"
|
||||
}
|
||||
|
||||
func debug(_ message: String) {
|
||||
logger.debug("\(formattedMessage(message))")
|
||||
}
|
||||
|
||||
func info(_ message: String) {
|
||||
logger.info("\(formattedMessage(message))")
|
||||
}
|
||||
|
||||
func warning(_ message: String) {
|
||||
logger.warning("\(formattedMessage(message))")
|
||||
}
|
||||
|
||||
func error(_ message: String) {
|
||||
logger.error("\(formattedMessage(message))")
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
//
|
||||
// AppSettings.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import ServiceManagement
|
||||
|
||||
class AppSettings: @unchecked Sendable {
|
||||
private let logger = IceGlassLogger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
|
||||
category: "AppSettings"
|
||||
)
|
||||
|
||||
static let shared = AppSettings()
|
||||
|
||||
private enum UserDefaultsKey {
|
||||
static let launchAtLogin = "launchAtLogin"
|
||||
static let displayOption = "displayOption"
|
||||
static let statusBarOption = "statusBarOption"
|
||||
}
|
||||
|
||||
/// Controls which days are shown in the menu
|
||||
enum DisplayOption: String, CaseIterable {
|
||||
case yesterdayTodayTomorrow = "yesterdayTodayTomorrow"
|
||||
case todayTomorrow = "todayTomorrow"
|
||||
case todayOnly = "todayOnly"
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .yesterdayTodayTomorrow: return "Yesterday / Today / Tomorrow"
|
||||
case .todayTomorrow: return "Today / Tomorrow"
|
||||
case .todayOnly: return "Today"
|
||||
}
|
||||
}
|
||||
|
||||
func includedDates() -> Set<String> {
|
||||
switch self {
|
||||
case .yesterdayTodayTomorrow:
|
||||
return [Date.yesterdayET, Date.todayET, Date.tomorrowET]
|
||||
case .todayTomorrow:
|
||||
return [Date.todayET, Date.tomorrowET]
|
||||
case .todayOnly:
|
||||
return [Date.todayET]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls what number shows next to the menu bar icon
|
||||
enum StatusBarOption: String, CaseIterable {
|
||||
case gameCount = "gameCount"
|
||||
case gamesPlayed = "gamesPlayed"
|
||||
case gamesPlayedTotal = "gamesPlayedTotal"
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .gameCount: return "Game Count"
|
||||
case .gamesPlayed: return "Games Played"
|
||||
case .gamesPlayedTotal: return "Games Played / Total"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Launch at login
|
||||
var launchAtLogin: Bool {
|
||||
get { UserDefaults.standard.bool(forKey: UserDefaultsKey.launchAtLogin) }
|
||||
set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKey.launchAtLogin) }
|
||||
}
|
||||
|
||||
// Display option
|
||||
var displayOption: DisplayOption {
|
||||
get {
|
||||
if let rawValue = UserDefaults.standard.string(forKey: UserDefaultsKey.displayOption),
|
||||
let option = DisplayOption(rawValue: rawValue) {
|
||||
return option
|
||||
}
|
||||
return .yesterdayTodayTomorrow
|
||||
}
|
||||
set { UserDefaults.standard.set(newValue.rawValue, forKey: UserDefaultsKey.displayOption) }
|
||||
}
|
||||
|
||||
// Status bar option
|
||||
var statusBarOption: StatusBarOption {
|
||||
get {
|
||||
if let rawValue = UserDefaults.standard.string(forKey: UserDefaultsKey.statusBarOption),
|
||||
let option = StatusBarOption(rawValue: rawValue) {
|
||||
return option
|
||||
}
|
||||
return .gameCount
|
||||
}
|
||||
set { UserDefaults.standard.set(newValue.rawValue, forKey: UserDefaultsKey.statusBarOption) }
|
||||
}
|
||||
|
||||
func updateLoginItem(enabled: Bool) {
|
||||
do {
|
||||
if enabled {
|
||||
try SMAppService.mainApp.register()
|
||||
} else {
|
||||
try SMAppService.mainApp.unregister()
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to \(enabled ? "enable" : "disable") launch at login: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private init() {}
|
||||
}
|
||||
@@ -366,7 +366,7 @@ class MenuManager: @unchecked Sendable {
|
||||
trailing = ""
|
||||
} else if let n = series.nextGameNumber {
|
||||
statusTag = "(Game \(n))"
|
||||
trailing = roundItem.nextGame.map { Self.nextGameLabel(for: $0) } ?? ""
|
||||
trailing = roundItem.nextGame?.nextGameLabel ?? ""
|
||||
} else {
|
||||
statusTag = ""
|
||||
trailing = ""
|
||||
@@ -381,28 +381,4 @@ class MenuManager: @unchecked Sendable {
|
||||
return item
|
||||
}
|
||||
|
||||
private static func nextGameLabel(for game: Scoreboard.Game) -> String {
|
||||
let state = game.parsedGameState
|
||||
if state.isLive {
|
||||
return state.shortTag
|
||||
}
|
||||
let dayLabel: String
|
||||
switch game.gameDate {
|
||||
case Date.todayET: dayLabel = "Today"
|
||||
case Date.tomorrowET: dayLabel = "Tomorrow"
|
||||
case Date.yesterdayET: dayLabel = "Yesterday"
|
||||
default:
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.timeZone = TimeZone(identifier: "America/New_York")
|
||||
if let date = formatter.date(from: game.gameDate) {
|
||||
dayLabel = date.formatDateET(format: "EEE")
|
||||
} else {
|
||||
dayLabel = ""
|
||||
}
|
||||
}
|
||||
let time = game.startTimeET.trimmingCharacters(in: .whitespaces)
|
||||
let base = "\(dayLabel) \(time)"
|
||||
return state == .pre ? "\(base) (PRE)" : base
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
//
|
||||
// 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) }
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
//
|
||||
// GameState.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum GameState: String, Codable {
|
||||
case future = "FUT" // More than 30 minutes prior to game start
|
||||
case pre = "PRE" // Pre-game, <30 minutes until puck drops
|
||||
case live = "LIVE" // Game has started
|
||||
case crit = "CRIT" // Last 5 minutes of regulation, OT or SO
|
||||
case over = "OVER" // Soft final
|
||||
case final_ = "FINAL" // Hard final
|
||||
case official = "OFF" // Official
|
||||
|
||||
var isLive: Bool {
|
||||
self == .live || self == .crit
|
||||
}
|
||||
|
||||
var isOver: Bool {
|
||||
self == .over || self == .final_ || self == .official
|
||||
}
|
||||
|
||||
var isFuture: Bool {
|
||||
self == .future || self == .pre
|
||||
}
|
||||
|
||||
/// Short tag for display in menu rows. Empty for future games — the start
|
||||
/// time already implies that state.
|
||||
var shortTag: String {
|
||||
switch self {
|
||||
case .future: return ""
|
||||
case .pre: return "PRE"
|
||||
case .live: return "LIVE"
|
||||
case .crit: return "CRIT"
|
||||
case .over: return "OVER"
|
||||
case .final_: return "FINAL"
|
||||
case .official: return "OFF"
|
||||
}
|
||||
}
|
||||
|
||||
var pollingInterval: PollingInterval {
|
||||
switch self {
|
||||
case .future:
|
||||
return .gameDay
|
||||
case .pre:
|
||||
return .preGame
|
||||
case .live, .crit:
|
||||
return .liveGame
|
||||
case .over, .final_:
|
||||
return .everyMinute
|
||||
case .official:
|
||||
return .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
//
|
||||
// PlayByPlayModel.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PlayByPlay: Codable {
|
||||
let awayTeam: Team
|
||||
let homeTeam: Team
|
||||
let plays: [Play]
|
||||
let rosterSpots: [RosterSpot]
|
||||
|
||||
struct Team: Codable {
|
||||
let id: Int
|
||||
let abbrev: String
|
||||
}
|
||||
|
||||
struct Play: Codable {
|
||||
let typeDescKey: String
|
||||
let situationCode: String?
|
||||
let details: Details?
|
||||
|
||||
struct Details: Codable {
|
||||
let scoringPlayerId: Int?
|
||||
let eventOwnerTeamId: Int?
|
||||
let awayScore: Int?
|
||||
let homeScore: Int?
|
||||
}
|
||||
}
|
||||
|
||||
struct RosterSpot: Codable {
|
||||
let teamId: Int
|
||||
let playerId: Int
|
||||
let firstName: LocalizedString
|
||||
let lastName: LocalizedString
|
||||
let sweaterNumber: Int
|
||||
|
||||
struct LocalizedString: Codable {
|
||||
let `default`: String
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the goal play whose trailing score matches the given totals exactly.
|
||||
/// Returns nil if play-by-play hasn't caught up with the scoreboard yet —
|
||||
/// safer than guessing, since a stale fallback could name the previous scorer.
|
||||
func goal(matchingAwayScore awayScore: Int, homeScore: Int) -> Play? {
|
||||
plays.last { play in
|
||||
play.typeDescKey == "goal"
|
||||
&& play.details?.awayScore == awayScore
|
||||
&& play.details?.homeScore == homeScore
|
||||
}
|
||||
}
|
||||
|
||||
func player(id: Int) -> RosterSpot? {
|
||||
rosterSpots.first { $0.playerId == id }
|
||||
}
|
||||
|
||||
/// Derived strength tag for a goal ("PPG", "SHG", "EN"), or nil for even-strength.
|
||||
/// `situationCode` is 4 digits: awayGoalie, awaySkaters, homeSkaters, homeGoalie.
|
||||
static func strengthTag(situationCode: String?, scoringTeamIsAway: Bool) -> String? {
|
||||
guard let code = situationCode, code.count == 4 else { return nil }
|
||||
let digits = code.compactMap { $0.wholeNumberValue }
|
||||
guard digits.count == 4 else { return nil }
|
||||
let awayGoalie = digits[0]
|
||||
let awaySkaters = digits[1]
|
||||
let homeSkaters = digits[2]
|
||||
let homeGoalie = digits[3]
|
||||
|
||||
let scoringSkaters = scoringTeamIsAway ? awaySkaters : homeSkaters
|
||||
let opposingSkaters = scoringTeamIsAway ? homeSkaters : awaySkaters
|
||||
let opposingGoalie = scoringTeamIsAway ? homeGoalie : awayGoalie
|
||||
|
||||
if opposingGoalie == 0 { return "EN" }
|
||||
if scoringSkaters > opposingSkaters { return "PPG" }
|
||||
if scoringSkaters < opposingSkaters { return "SHG" }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct GoalScorer {
|
||||
let name: String
|
||||
let sweaterNumber: Int
|
||||
let strength: String?
|
||||
|
||||
/// "#14 J. Eriksson Ek (PPG)" — strength suffix omitted for even-strength goals.
|
||||
var displayLine: String {
|
||||
let head = "#\(sweaterNumber) \(name)"
|
||||
if let strength = strength { return "\(head) (\(strength))" }
|
||||
return head
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
let gameCenterLink: String
|
||||
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 {
|
||||
ISO8601DateFormatter().date(from: startTimeUTC) ?? .now
|
||||
}
|
||||
|
||||
var gameCenterUrl: String {
|
||||
"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")
|
||||
var startTimeET: String {
|
||||
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)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//
|
||||
// StandingsModel.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Standings: Codable {
|
||||
let standings: [TeamStanding]
|
||||
|
||||
struct LocalizedString: Codable {
|
||||
let `default`: String
|
||||
}
|
||||
|
||||
struct TeamStanding: Codable {
|
||||
let teamAbbrev: LocalizedString
|
||||
let teamLogo: String
|
||||
let gamesPlayed: Int
|
||||
let wins: Int
|
||||
let losses: Int
|
||||
let otLosses: Int
|
||||
let points: Int
|
||||
let seasonId: Int
|
||||
}
|
||||
|
||||
/// Total unique games played across the league (each game counted once)
|
||||
var totalGamesPlayed: Int {
|
||||
standings.reduce(0) { $0 + $1.gamesPlayed } / 2
|
||||
}
|
||||
|
||||
/// Total regular season games: 32 teams * 82 games / 2
|
||||
static let totalRegularSeasonGames = 1312
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
@@ -1,45 +0,0 @@
|
||||
//
|
||||
// ApiService.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
typealias ApiServiceCallback = (_ jsonData: Data, _ response: URLResponse) -> Void
|
||||
|
||||
class ApiService: @unchecked Sendable {
|
||||
private let logger = IceGlassLogger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
|
||||
category: "ApiService"
|
||||
)
|
||||
|
||||
let url: URL
|
||||
let callback: ApiServiceCallback
|
||||
|
||||
var previousFetchTime: Date = .distantPast
|
||||
|
||||
init(url: URL?, callback: @escaping ApiServiceCallback) {
|
||||
guard let url = url else {
|
||||
AppTerminator.terminate()
|
||||
self.url = URL(string: "")!
|
||||
self.callback = { (_: Data, _: URLResponse) in }
|
||||
return
|
||||
}
|
||||
self.url = url
|
||||
self.callback = callback
|
||||
}
|
||||
|
||||
func fetch() async {
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
logger.info("Polling: \(self.url)")
|
||||
logger.debug("Previous: \(Date.now.timeIntervalSince(previousFetchTime).humanReadableTime())")
|
||||
previousFetchTime = Date.now
|
||||
callback(data, response)
|
||||
} catch {
|
||||
logger.error("Invalid response from \(self.url) \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
//
|
||||
// MainService.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class MainService: @unchecked Sendable {
|
||||
private let logger = IceGlassLogger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
|
||||
category: "MainService"
|
||||
)
|
||||
|
||||
static let shared = MainService()
|
||||
|
||||
private lazy var menuManager = MenuManager.shared
|
||||
private lazy var statusItemManager = StatusItemManager.shared
|
||||
private lazy var settings = AppSettings.shared
|
||||
private lazy var notificationManager = NotificationManager.shared
|
||||
|
||||
private var pollingTimer: Timer?
|
||||
private var scoreboardApi: ApiService?
|
||||
private var standingsApi: ApiService?
|
||||
private var bracketApi: ApiService?
|
||||
private var bracketApiSeasonYear: Int?
|
||||
|
||||
/// All game days from the API (full window: yesterday/today/tomorrow)
|
||||
private var allGamesByDate: [Scoreboard.GameDay] = []
|
||||
|
||||
/// Current standings data
|
||||
var standings: Standings?
|
||||
|
||||
/// Current playoff bracket (nil during regular season or before first fetch)
|
||||
var bracket: PlayoffBracket?
|
||||
|
||||
/// Previous game snapshots for detecting state/score changes (keyed by game ID)
|
||||
private var previousGameStates: [Int: GameSnapshot] = [:]
|
||||
|
||||
/// Whether this is the first fe tch (suppress notifications on initial load)
|
||||
private var isFirstFetch = true
|
||||
|
||||
/// Set during change detection when a playoff game transitions to a final state,
|
||||
/// so the next scoreboard cycle can force a bracket refresh.
|
||||
private var hadPlayoffFinalTransition = false
|
||||
|
||||
struct GameSnapshot {
|
||||
let gameState: String
|
||||
let awayScore: Int?
|
||||
let homeScore: Int?
|
||||
}
|
||||
|
||||
/// Game days filtered by display option
|
||||
var gamesByDate: [Scoreboard.GameDay] {
|
||||
let includedDates = settings.displayOption.includedDates()
|
||||
return allGamesByDate.filter { includedDates.contains($0.date) }
|
||||
}
|
||||
|
||||
/// Status bar text based on current settings
|
||||
var statusBarText: String {
|
||||
switch settings.statusBarOption {
|
||||
case .gameCount:
|
||||
let gameDays = gamesByDate
|
||||
if gameDays.isEmpty { return "" }
|
||||
return gameDays.map { "\($0.games.count)" }.joined(separator: "/")
|
||||
|
||||
case .gamesPlayed:
|
||||
return "\(standings?.totalGamesPlayed ?? 0)"
|
||||
|
||||
case .gamesPlayedTotal:
|
||||
return "\(standings?.totalGamesPlayed ?? 0)/\(Standings.totalRegularSeasonGames)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any game across all days is currently live
|
||||
var anyGameLive: Bool {
|
||||
allGamesByDate.flatMap(\.games).contains { $0.parsedGameState.isLive }
|
||||
}
|
||||
|
||||
/// Whether any game is in pre-game state
|
||||
var anyGamePre: Bool {
|
||||
allGamesByDate.flatMap(\.games).contains { $0.parsedGameState == .pre }
|
||||
}
|
||||
|
||||
/// Whether any game today is scheduled (future)
|
||||
var anyGameToday: Bool {
|
||||
let today = Date.todayET
|
||||
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] {
|
||||
guard let bracket = bracket else { return [] }
|
||||
let windowGames = allGamesByDate.flatMap(\.games)
|
||||
return bracket.currentRoundSeries.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 }
|
||||
return RoundSeriesItem(series: series, nextGame: nextGame)
|
||||
}
|
||||
}
|
||||
|
||||
struct RoundSeriesItem {
|
||||
let series: PlayoffBracket.Series
|
||||
let nextGame: Scoreboard.Game?
|
||||
}
|
||||
|
||||
/// The best polling interval based on current game states
|
||||
var bestPollingInterval: PollingInterval {
|
||||
if anyGameLive { return .liveGame }
|
||||
if anyGamePre { return .preGame }
|
||||
if anyGameToday { return .gameDay }
|
||||
return .idle
|
||||
}
|
||||
|
||||
private init() {
|
||||
logger.debug("Initializing")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
AppTerminator.terminate()
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.initApis()
|
||||
self.reschedulePollingTimer(.bootstrap)
|
||||
await self.fetchScoreboard()
|
||||
await self.fetchStandings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func initApis() {
|
||||
scoreboardApi = ApiService(
|
||||
url: URL(string: "https://api-web.nhle.com/v1/scoreboard/now")
|
||||
) { [weak self] data, _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
do {
|
||||
let scoreboard = try JSONDecoder().decode(Scoreboard.self, from: data)
|
||||
|
||||
let yesterday = Date.yesterdayET
|
||||
let today = Date.todayET
|
||||
let tomorrow = Date.tomorrowET
|
||||
let windowDates = Set([yesterday, today, tomorrow])
|
||||
|
||||
let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.date) }
|
||||
|
||||
if !self.isFirstFetch {
|
||||
self.detectChanges(in: filtered)
|
||||
}
|
||||
|
||||
self.allGamesByDate = filtered
|
||||
self.updateSnapshots(from: filtered)
|
||||
|
||||
let wasFirstFetch = self.isFirstFetch
|
||||
if self.isFirstFetch {
|
||||
self.isFirstFetch = false
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Fire a test game-start notification on startup so the dev
|
||||
// loop doesn't require clicking through the menu each time.
|
||||
if wasFirstFetch, let game = filtered.flatMap(\.games).first {
|
||||
self.notificationManager.notifyGameStarted(game, bypassDedup: true)
|
||||
}
|
||||
#endif
|
||||
|
||||
self.logger.info("Scoreboard updated: \(filtered.map { "\($0.date): \($0.games.count) games" }.joined(separator: ", "))")
|
||||
|
||||
let interval = self.bestPollingInterval
|
||||
let shouldForceBracket = self.hadPlayoffFinalTransition
|
||||
self.hadPlayoffFinalTransition = false
|
||||
Task { @MainActor in
|
||||
self.reschedulePollingTimer(interval)
|
||||
await self.refreshBracketIfNeeded(from: filtered, force: shouldForceBracket)
|
||||
}
|
||||
|
||||
self.updateUI()
|
||||
} catch {
|
||||
self.logger.error("Failed to decode scoreboard: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
standingsApi = ApiService(
|
||||
url: URL(string: "https://api-web.nhle.com/v1/standings/\(Date.todayET)")
|
||||
) { [weak self] data, _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
do {
|
||||
self.standings = try JSONDecoder().decode(Standings.self, from: data)
|
||||
self.logger.info("Standings updated: \(self.standings?.standings.count ?? 0) teams")
|
||||
self.updateUI()
|
||||
} catch {
|
||||
self.logger.error("Failed to decode standings: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateUI() {
|
||||
statusItemManager.updateStatusText(statusBarText)
|
||||
menuManager.scoreboardChanged()
|
||||
}
|
||||
|
||||
// MARK: - Change Detection
|
||||
|
||||
private func detectChanges(in gameDays: [Scoreboard.GameDay]) {
|
||||
for gameDay in gameDays {
|
||||
for game in gameDay.games {
|
||||
guard let previous = previousGameStates[game.id] else { continue }
|
||||
|
||||
let previousState = GameState(rawValue: previous.gameState)
|
||||
let currentState = game.parsedGameState
|
||||
|
||||
if let prevState = previousState, prevState.isFuture, currentState.isLive {
|
||||
logger.info("Game \(game.id) started: \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)")
|
||||
notificationManager.notifyGameStarted(game)
|
||||
}
|
||||
|
||||
// Game ended: transition to any over-state (OVER/FINAL/OFF)
|
||||
if let prevState = previousState, !prevState.isOver, currentState.isOver {
|
||||
logger.info("Game \(game.id) ended: \(game.awayTeam.abbrev) \(game.awayTeam.score ?? 0) — \(game.homeTeam.abbrev) \(game.homeTeam.score ?? 0)")
|
||||
notificationManager.notifyGameEnded(game)
|
||||
}
|
||||
|
||||
if game.gameType == 3,
|
||||
let prevState = previousState, !prevState.isOver, currentState.isOver {
|
||||
hadPlayoffFinalTransition = true
|
||||
}
|
||||
|
||||
if let prevAway = previous.awayScore, let prevHome = previous.homeScore,
|
||||
let curAway = game.awayTeam.score, let curHome = game.homeTeam.score {
|
||||
if curAway > prevAway {
|
||||
logger.info("Goal! \(game.awayTeam.abbrev) scored in game \(game.id): \(curAway):\(curHome)")
|
||||
handleGoal(game: game, scoringTeam: game.awayTeam, awayScore: curAway, homeScore: curHome)
|
||||
}
|
||||
if curHome > prevHome {
|
||||
logger.info("Goal! \(game.homeTeam.abbrev) scored in game \(game.id): \(curAway):\(curHome)")
|
||||
handleGoal(game: game, scoringTeam: game.homeTeam, awayScore: curAway, homeScore: curHome)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleGoal(game: Scoreboard.Game, scoringTeam: Scoreboard.Game.Team, awayScore: Int, homeScore: Int) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let scorer = await self.fetchGoalScorer(
|
||||
gameId: game.id,
|
||||
awayScore: awayScore,
|
||||
homeScore: homeScore
|
||||
)
|
||||
self.notificationManager.notifyGoalScored(game, scoringTeam: scoringTeam, scorer: scorer)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchGoalScorer(gameId: Int, awayScore: Int, homeScore: Int) async -> GoalScorer? {
|
||||
guard let url = URL(string: "https://api-web.nhle.com/v1/gamecenter/\(gameId)/play-by-play") else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let pbp = try JSONDecoder().decode(PlayByPlay.self, from: data)
|
||||
guard let goal = pbp.goal(matchingAwayScore: awayScore, homeScore: homeScore),
|
||||
let playerId = goal.details?.scoringPlayerId,
|
||||
let player = pbp.player(id: playerId) else {
|
||||
logger.info("Play-by-play not yet caught up for goal at \(awayScore)-\(homeScore) in game \(gameId)")
|
||||
return nil
|
||||
}
|
||||
let scoringTeamIsAway = goal.details?.eventOwnerTeamId == pbp.awayTeam.id
|
||||
let strength = PlayByPlay.strengthTag(
|
||||
situationCode: goal.situationCode,
|
||||
scoringTeamIsAway: scoringTeamIsAway
|
||||
)
|
||||
let firstInitial = player.firstName.default.first.map { "\($0)." } ?? ""
|
||||
let name = "\(firstInitial) \(player.lastName.default)".trimmingCharacters(in: .whitespaces)
|
||||
return GoalScorer(name: name, sweaterNumber: player.sweaterNumber, strength: strength)
|
||||
} catch {
|
||||
logger.error("Failed to fetch play-by-play for game \(gameId): \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSnapshots(from gameDays: [Scoreboard.GameDay]) {
|
||||
for gameDay in gameDays {
|
||||
for game in gameDay.games {
|
||||
previousGameStates[game.id] = GameSnapshot(
|
||||
gameState: game.gameState,
|
||||
awayScore: game.awayTeam.score,
|
||||
homeScore: game.homeTeam.score
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Polling
|
||||
|
||||
private func reschedulePollingTimer(_ pollingInterval: PollingInterval) {
|
||||
if !Thread.isMainThread {
|
||||
logger.warning("reschedulePollingTimer called not on main thread")
|
||||
return
|
||||
}
|
||||
|
||||
let rescheduleTolerance: TimeInterval = 3
|
||||
var needsRescheduling = false
|
||||
|
||||
if self.pollingTimer == nil {
|
||||
needsRescheduling = true
|
||||
} else if !self.pollingTimer!.isValid {
|
||||
needsRescheduling = true
|
||||
} else if abs(self.pollingTimer!.fireDate.timeIntervalSinceNow - pollingInterval.rawValue) > rescheduleTolerance {
|
||||
needsRescheduling = true
|
||||
}
|
||||
|
||||
if needsRescheduling {
|
||||
self.logger.debug("Rescheduling polling timer to \(pollingInterval.rawValue)s")
|
||||
self.pollingTimer = Timer.startTimer(
|
||||
timer: self.pollingTimer,
|
||||
interval: pollingInterval.rawValue
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
await self?.fetchScoreboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchScoreboard() async {
|
||||
await scoreboardApi?.fetch()
|
||||
}
|
||||
|
||||
private func fetchStandings() async {
|
||||
await standingsApi?.fetch()
|
||||
}
|
||||
|
||||
func fetchAll() async {
|
||||
await fetchScoreboard()
|
||||
await fetchStandings()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
bracket = nil
|
||||
return
|
||||
}
|
||||
|
||||
let seasonYear = playoffGame.season / 10_000
|
||||
let seasonChanged = bracketApi == nil || bracketApiSeasonYear != seasonYear
|
||||
if seasonChanged {
|
||||
bracketApiSeasonYear = seasonYear
|
||||
bracketApi = ApiService(
|
||||
url: URL(string: "https://api-web.nhle.com/v1/playoff-bracket/\(seasonYear + 1)")
|
||||
) { [weak self] data, _ in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
self.bracket = try JSONDecoder().decode(PlayoffBracket.self, from: data)
|
||||
self.logger.info("Bracket updated: \(self.bracket?.series.count ?? 0) series (round \(self.bracket?.currentRound ?? 0))")
|
||||
self.updateUI()
|
||||
} catch {
|
||||
self.logger.error("Failed to decode bracket: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bracket == nil || seasonChanged || force {
|
||||
await bracketApi?.fetch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
//
|
||||
// PollingInterval.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum PollingInterval: TimeInterval {
|
||||
case idle = 3600
|
||||
case bootstrap = 42
|
||||
case liveGame = 7
|
||||
case gameDay = 600
|
||||
case preGame = 180
|
||||
case everyMinute = 60
|
||||
}
|
||||