Add playoff round view, game numbers, goal scorer notifications, standings
- Fetch NHL standings and surface league/season game counts in the menu bar
- Prefix regular-season rows with the league-wide game number (from gameId)
- New ROUND section shows each active playoff series (matchup, series score,
next game number + time) derived from /v1/playoff-bracket; rows always open
the NHL series page so completed series remain clickable
- Goal notifications include scorer sweater, abbreviated name, and strength
(PPG/SHG/EN), resolved via /v1/gamecenter/{id}/play-by-play
- Drop the per-team filter submenu and NHLTeam enum
- Regenerate AppIcon with the full 10-size macOS set (alpha preserved) so
notifications render the app icon correctly; rename the iOS marketing PNG
to icon-ios-1024.png
- gitignore .claude/ local tooling settings
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) }
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
//
|
||||
// NHLTeam.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NHLTeam: String, CaseIterable {
|
||||
case ana = "ANA"
|
||||
case bos = "BOS"
|
||||
case buf = "BUF"
|
||||
case car = "CAR"
|
||||
case cbj = "CBJ"
|
||||
case cgy = "CGY"
|
||||
case chi = "CHI"
|
||||
case col = "COL"
|
||||
case dal = "DAL"
|
||||
case det = "DET"
|
||||
case edm = "EDM"
|
||||
case fla = "FLA"
|
||||
case lak = "LAK"
|
||||
case min = "MIN"
|
||||
case mtl = "MTL"
|
||||
case njd = "NJD"
|
||||
case nsh = "NSH"
|
||||
case nyi = "NYI"
|
||||
case nyr = "NYR"
|
||||
case ott = "OTT"
|
||||
case phi = "PHI"
|
||||
case pit = "PIT"
|
||||
case sea = "SEA"
|
||||
case sjs = "SJS"
|
||||
case stl = "STL"
|
||||
case tbl = "TBL"
|
||||
case tor = "TOR"
|
||||
case uta = "UTA"
|
||||
case van = "VAN"
|
||||
case vgk = "VGK"
|
||||
case wpg = "WPG"
|
||||
case wsh = "WSH"
|
||||
|
||||
var abbreviation: String { rawValue }
|
||||
|
||||
var fullName: String {
|
||||
switch self {
|
||||
case .ana: return "Anaheim Ducks"
|
||||
case .bos: return "Boston Bruins"
|
||||
case .buf: return "Buffalo Sabres"
|
||||
case .car: return "Carolina Hurricanes"
|
||||
case .cbj: return "Columbus Blue Jackets"
|
||||
case .cgy: return "Calgary Flames"
|
||||
case .chi: return "Chicago Blackhawks"
|
||||
case .col: return "Colorado Avalanche"
|
||||
case .dal: return "Dallas Stars"
|
||||
case .det: return "Detroit Red Wings"
|
||||
case .edm: return "Edmonton Oilers"
|
||||
case .fla: return "Florida Panthers"
|
||||
case .lak: return "Los Angeles Kings"
|
||||
case .min: return "Minnesota Wild"
|
||||
case .mtl: return "Montreal Canadiens"
|
||||
case .njd: return "New Jersey Devils"
|
||||
case .nsh: return "Nashville Predators"
|
||||
case .nyi: return "New York Islanders"
|
||||
case .nyr: return "New York Rangers"
|
||||
case .ott: return "Ottawa Senators"
|
||||
case .phi: return "Philadelphia Flyers"
|
||||
case .pit: return "Pittsburgh Penguins"
|
||||
case .sea: return "Seattle Kraken"
|
||||
case .sjs: return "San Jose Sharks"
|
||||
case .stl: return "St. Louis Blues"
|
||||
case .tbl: return "Tampa Bay Lightning"
|
||||
case .tor: return "Toronto Maple Leafs"
|
||||
case .uta: return "Utah Hockey Club"
|
||||
case .van: return "Vancouver Canucks"
|
||||
case .vgk: return "Vegas Golden Knights"
|
||||
case .wpg: return "Winnipeg Jets"
|
||||
case .wsh: return "Washington Capitals"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -73,30 +73,58 @@ struct Scoreboard: Codable {
|
||||
"https://videocast.nhl.com/game/\(id)/usnded?autoplay=true"
|
||||
}
|
||||
|
||||
/// Time string in ET for display (e.g., "7:00 PM")
|
||||
/// Time string in ET, right-aligned to 8 chars (e.g. " 9:30 PM", "10:00 PM")
|
||||
var startTimeET: String {
|
||||
date.formatDateET(format: "h:mm a")
|
||||
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 (FINAL)" or "DAL @ TOR Today @ 7:30 PM"
|
||||
/// Formatted menu title:
|
||||
/// "NYR @ WAS 0: 2 9:30 PM" (finished/live — padded score + time)
|
||||
/// "DAL @ TOR 7:30 PM" (future — no score gap)
|
||||
var menuTitle: String {
|
||||
let state = parsedGameState
|
||||
let matchup = "\(awayTeam.abbrev) @ \(homeTeam.abbrev)"
|
||||
|
||||
if state.isFuture {
|
||||
let isToday = gameDate == Date.todayET
|
||||
let prefix = isToday ? "Today @ " : ""
|
||||
return "\(matchup) \(prefix)\(startTimeET)"
|
||||
return "\(matchup) \(startTimeET)"
|
||||
}
|
||||
|
||||
// Has scores
|
||||
let score = "\(awayTeam.score ?? 0):\(homeTeam.score ?? 0)"
|
||||
return "\(matchup) \(score) (\(gameState))"
|
||||
let aScore = String(format: "%2d", awayTeam.score ?? 0)
|
||||
let hScore = String(format: "%-2d", homeTeam.score ?? 0)
|
||||
return "\(matchup) \(aScore):\(hScore) \(startTimeET)"
|
||||
}
|
||||
|
||||
/// Whether this game involves a specific team
|
||||
func involves(team abbrev: String) -> Bool {
|
||||
awayTeam.abbrev == abbrev || homeTeam.abbrev == abbrev
|
||||
/// 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