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:
2026-04-18 21:51:27 -04:00
parent 8f8f8b2755
commit 57358797e1
44 changed files with 596 additions and 286 deletions
+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) }
}
}
-82
View File
@@ -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"
}
}
}
+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
}
}
+40 -12
View File
@@ -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: 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
}