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.
This commit is contained in:
2026-04-25 06:34:36 -04:00
parent 18c4ef64d6
commit aaffa3771c
70 changed files with 1011 additions and 65 deletions
@@ -0,0 +1,14 @@
//
// Date+easternTimeZone.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
extension Date {
static var easternTimeZone: TimeZone {
TimeZone(identifier: "America/New_York")!
}
}
+16
View File
@@ -0,0 +1,16 @@
//
// 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
}
}
+21
View File
@@ -0,0 +1,21 @@
//
// 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"
}
}
+68
View File
@@ -0,0 +1,68 @@
//
// 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
}
}
@@ -0,0 +1,38 @@
//
// Game+nextGameLabel.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
extension Scoreboard.Game {
/// Friendly "next game" label used by both the macOS menu and the iOS
/// playoff series row: "Today 9:30 PM", "Tomorrow 7:00 PM", "Wed 7:00 PM (PRE)",
/// or just "LIVE" when the game is in progress.
var nextGameLabel: String {
let state = parsedGameState
if state.isLive {
return state.shortTag
}
let dayLabel: String
switch 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: gameDate) {
dayLabel = date.formatDateET(format: "EEE")
} else {
dayLabel = ""
}
}
let time = startTimeET.trimmingCharacters(in: .whitespaces)
let base = "\(dayLabel) \(time)"
return state == .pre ? "\(base) (PRE)" : base
}
}
@@ -0,0 +1,36 @@
//
// 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)
}
}
+19
View File
@@ -0,0 +1,19 @@
//
// 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
)
}
}
+44
View File
@@ -0,0 +1,44 @@
//
// 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))")
}
}
+74
View File
@@ -0,0 +1,74 @@
//
// 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) }
}
}
+59
View File
@@ -0,0 +1,59 @@
//
// 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
}
}
}
+94
View File
@@ -0,0 +1,94 @@
//
// 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
}
}
+132
View File
@@ -0,0 +1,132 @@
//
// 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: 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)!)
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
//
// 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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+42
View File
@@ -0,0 +1,42 @@
//
// 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 {
preconditionFailure("ApiService initialised with nil URL — caller bug.")
}
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)")
}
}
}
+475
View File
@@ -0,0 +1,475 @@
//
// MainService.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// Platform-agnostic callback so MainService doesn't import AppKit/UIKit.
/// macOS sets this to drive MenuManager + StatusItemManager + NotificationManager;
/// iOS sets it to invalidate its @Observable view model.
@MainActor
protocol MainServiceObserver: AnyObject {
/// Fired after every successful scoreboard / standings / bracket update.
func mainServiceDidUpdate()
/// Fired when a future game transitions to live. macOS shows a notification;
/// iOS v1 ignores (no notifications).
func mainServiceDidDetectGameStart(_ game: Scoreboard.Game)
/// Fired when a goal is detected. `scorer` may be nil if play-by-play hasn't
/// caught up yet.
func mainServiceDidDetectGoal(
_ game: Scoreboard.Game,
scoringTeam: Scoreboard.Game.Team,
scorer: GoalScorer?
)
/// Fired when a game transitions to a final state (OVER/FINAL/OFF).
func mainServiceDidDetectGameEnd(_ game: Scoreboard.Game)
}
extension MainServiceObserver {
func mainServiceDidDetectGameStart(_: Scoreboard.Game) {}
func mainServiceDidDetectGoal(_: Scoreboard.Game, scoringTeam: Scoreboard.Game.Team, scorer: GoalScorer?) {}
func mainServiceDidDetectGameEnd(_: Scoreboard.Game) {}
}
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 settings = AppSettings.shared
private let cache = NHLDataCache()
/// Set by each platform's app entry to receive update callbacks.
/// Always invoked on the main actor by the methods below; the property
/// itself is unisolated so MainService.shared can be referenced from
/// nonisolated contexts (e.g. AppDelegate's stored property init).
nonisolated(unsafe) weak var observer: (any MainServiceObserver)?
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?
/// Timestamp of the most recent successful fetch (any endpoint).
/// Surface this in the iOS UI as the "as of" indicator.
private(set) var lastUpdated: Date?
/// Previous game snapshots for detecting state/score changes (keyed by game ID)
private var previousGameStates: [Int: GameSnapshot] = [:]
/// Whether this is the first fetch (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 { return }
Task { @MainActor in
await self.loadFromCache()
self.initApis()
self.reschedulePollingTimer(.bootstrap)
await self.fetchScoreboard()
await self.fetchStandings()
}
}
}
// MARK: - Cache
/// Loads the persisted snapshot (if any) so the UI can paint last-known
/// data immediately. The fresh fetch in `init` then overwrites in-memory
/// state and the cache file.
private func loadFromCache() async {
guard let snapshot = await cache.load() else { return }
if let scoreboard = snapshot.scoreboard {
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) }
self.allGamesByDate = filtered
self.updateSnapshots(from: filtered)
}
self.standings = snapshot.standings
self.bracket = snapshot.bracket
self.lastUpdated = snapshot.lastUpdated
logger.info("Loaded cached snapshot from \(snapshot.lastUpdated)")
Task { @MainActor in self.observer?.mainServiceDidUpdate() }
}
private func persistCache() {
// Reconstruct a synthetic Scoreboard from the windowed allGamesByDate
// so the cached payload is exactly what we'd render on next launch.
let scoreboard = Scoreboard(
focusedDate: Date.todayET,
focusedDateCount: allGamesByDate.first { $0.date == Date.todayET }?.games.count ?? 0,
gamesByDate: allGamesByDate
)
let snapshot = CachedSnapshot(
lastUpdated: Date(),
scoreboard: scoreboard,
standings: standings,
bracket: bracket
)
Task { [cache] in
await cache.save(snapshot)
}
}
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)
self.lastUpdated = Date()
if self.isFirstFetch {
self.isFirstFetch = false
}
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.persistCache()
self.notifyObserverDidUpdate()
} 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.lastUpdated = Date()
self.logger.info("Standings updated: \(self.standings?.standings.count ?? 0) teams")
self.persistCache()
self.notifyObserverDidUpdate()
} catch {
self.logger.error("Failed to decode standings: \(error.localizedDescription)")
}
}
}
private func notifyObserverDidUpdate() {
Task { @MainActor in
self.observer?.mainServiceDidUpdate()
}
}
/// Fires `mainServiceDidUpdate` without triggering a network fetch call
/// after settings changes that affect what the existing data renders as.
func updateUI() {
notifyObserverDidUpdate()
}
// 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)")
let captured = game
Task { @MainActor in self.observer?.mainServiceDidDetectGameStart(captured) }
}
// 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)")
let captured = game
Task { @MainActor in self.observer?.mainServiceDidDetectGameEnd(captured) }
}
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
)
await MainActor.run {
self.observer?.mainServiceDidDetectGoal(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()
}
}
}
}
/// Cancel the polling timer (call from iOS when entering background).
@MainActor
func suspendPolling() {
pollingTimer?.invalidate()
pollingTimer = nil
logger.debug("Polling suspended")
}
/// Resume polling at the appropriate interval (call from iOS on scenePhase=.active).
@MainActor
func resumePolling() {
reschedulePollingTimer(bestPollingInterval)
}
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.lastUpdated = Date()
self.logger.info("Bracket updated: \(self.bracket?.series.count ?? 0) series (round \(self.bracket?.currentRound ?? 0))")
self.persistCache()
self.notifyObserverDidUpdate()
} catch {
self.logger.error("Failed to decode bracket: \(error.localizedDescription)")
}
}
}
if bracket == nil || seasonChanged || force {
await bracketApi?.fetch()
}
}
}
+68
View File
@@ -0,0 +1,68 @@
//
// NHLDataCache.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// Snapshot of API state persisted between app launches so cold-launch shows
/// last-known data with an "as-of" timestamp instead of an empty page.
struct CachedSnapshot: Codable, Sendable {
let lastUpdated: Date
let scoreboard: Scoreboard?
let standings: Standings?
let bracket: PlayoffBracket?
}
/// Single-file Codable cache in Application Support. Atomic writes; fail-soft
/// reads corrupt or version-mismatched payloads return nil and the next
/// fetch overwrites with fresh data.
actor NHLDataCache {
private let logger = IceGlassLogger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
category: "NHLDataCache"
)
private let fileURL: URL
init(filename: String = "nhl-snapshot.json") {
let fm = FileManager.default
let supportDir = (try? fm.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)) ?? fm.temporaryDirectory
let bundleId = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass"
let appDir = supportDir.appendingPathComponent(bundleId, isDirectory: true)
try? fm.createDirectory(at: appDir, withIntermediateDirectories: true)
self.fileURL = appDir.appendingPathComponent(filename)
}
func load() -> CachedSnapshot? {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
do {
let data = try Data(contentsOf: fileURL)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(CachedSnapshot.self, from: data)
} catch {
logger.warning("Cache load failed (will refetch): \(error.localizedDescription)")
return nil
}
}
func save(_ snapshot: CachedSnapshot) {
do {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(snapshot)
try data.write(to: fileURL, options: .atomic)
} catch {
logger.error("Cache save failed: \(error.localizedDescription)")
}
}
}
+17
View File
@@ -0,0 +1,17 @@
//
// 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
}
+113
View File
@@ -0,0 +1,113 @@
//
// AppSettings.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
#if os(macOS)
import ServiceManagement
#endif
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) {
#if os(macOS)
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)")
}
#endif
}
private init() {}
}