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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: 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)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user